Compare commits

...
26 Commits
Author SHA1 Message Date
Ku6epXBOCTuK a2dd288851 feat: add tools into registry, add icons 2026-08-24 13:01:06 +05:00
Ku6epXBOCTuK cb5ccf6da8 feat: add alpha and morphology core tools 2026-08-24 12:02:42 +05:00
Ku6epXBOCTuK b434bf3ef3 docs: update plans 2026-08-24 11:10:07 +05:00
Ku6epXBOCTuK 200e971df5 fix: use toolcard as links at list page 2026-08-24 10:03:46 +05:00
Ku6epXBOCTuK 5e44a45457 feat: add tool search cards 2026-08-24 09:40:58 +05:00
Ku6epXBOCTuK 76f1770286 feat: add tool popularity and icons 2026-08-24 09:16:29 +05:00
Ku6epXBOCTuK 68476c3b8c docs: update plans 2026-08-24 08:49:25 +05:00
Ku6epXBOCTuK 702b96261d feat: combine tool in single card 2026-08-24 08:31:13 +05:00
Ku6epXBOCTuK 95ee598875 chore: add root script 2026-08-24 07:47:50 +05:00
Ku6epXBOCTuK 46af693082 docs: complete plan 2026-08-24 07:47:34 +05:00
Ku6epXBOCTuK 9fbb90b641 feat: move tool executor to web worker 2026-08-24 07:39:03 +05:00
Ku6epXBOCTuK c14301130f feat: add registry - background, blur, sharpen 2026-08-24 07:33:41 +05:00
Ku6epXBOCTuK fd5236c19b feat: add background and convolution core tools 2026-08-24 07:25:41 +05:00
Ku6epXBOCTuK 3d7371492f docs: add new plan 2026-08-24 07:04:22 +05:00
Ku6epXBOCTuK 1b3bad46d1 refactor: use standalone tool run executor 2026-08-24 06:53:18 +05:00
Ku6epXBOCTuK 474c1156aa docs: update plans 2026-08-24 06:52:40 +05:00
Ku6epXBOCTuK 91b3492231 feat: restore old toolchain button 2026-08-24 06:35:01 +05:00
Ku6epXBOCTuK 3a5acfbc45 refactor: change show mask flow - checkbox in params 2026-08-24 06:02:32 +05:00
Ku6epXBOCTuK 306c26a360 feat: add chain tools workflow 2026-08-24 05:52:07 +05:00
Ku6epXBOCTuK c93a8e493e feat: add tool search component, add chainable flag 2026-08-23 21:55:45 +05:00
Ku6epXBOCTuK ce7f5ff680 docs: update plan 2026-08-23 21:41:37 +05:00
Ku6epXBOCTuK 8532eb98c3 feat: add step picker\list\editor 2026-08-23 20:12:43 +05:00
Ku6epXBOCTuK 23c985bde9 feat: add pipeline for multi instrument flow 2026-08-23 15:45:29 +05:00
Ku6epXBOCTuK 8632e2e1cf docs: update plan 2026-08-23 15:22:00 +05:00
Ku6epXBOCTuK 6fae98b25c feat: add registry 2026-08-23 12:41:17 +05:00
Ku6epXBOCTuK f9a4973b47 feat: add core operations 2026-08-23 11:38:18 +05:00
53 changed files with 3806 additions and 289 deletions
@@ -1,6 +1,6 @@
# План: вторая волна EASY-инструментов
> Статус: план к выполнению.
> **СТАТУС: ВЫПОЛНЕН 2026-08-23.**
## 1. Принципы отбора
+60
View File
@@ -0,0 +1,60 @@
# План: герой-поиск v2 — иконки, популярность, карточки
> **СТАТУС: ВЫПОЛНЕН 2026-08-23.**
## 1. Что получается
Выдача поиска на главной превращается из списка строк в сетку карточек: слева иконка инструмента, справа название и описание. Инструменты получают два новых поля реестра — `popularity` (приоритет в выдаче) и `icon` (идентификатор иконки). Иконки переиспользуются в каталоге `/list-tools` и в легендах шагов цепочки. Поиск работает по-прежнему: нечёткий скоринг, клавиатура ↑↓ Enter Esc.
## 2. Данные реестра
- **`popularity?: number`** (0–100, по умолчанию 50) — приоритет внутри равного скоринга запроса. Значения проставляются вручную осознанно: базовые операции (обрезка, размер, удаление фона, конвертации JPG/PNG) — 80–95; цветовые и геометрия — 40–70; узкие и генераторы — 10–30.
- **`icon?: string`** — имя иконки из набора Lucide. Явный словарь-маппинг для всех инструментов в одном месте; отсутствие записи даёт нейтральную заглушку (Wrench).
- Тест целостности расширяется: популярность в диапазоне, иконка — непустая строка из разрешённого набора.
## 3. Иконки
Зависимость `lucide-svelte` (MIT) — один пакет, иконки рендерятся компонентами, дерево не раздувается за счёт выборочного импорта.
Маппинг ведётся одним объектом `tool-icons.ts` рядом с реестром: 51 запись вида `'crop-png': Crop`. Подбор по смыслу: обрезка — Crop, размер — Scaling, поворот — RotateCw, отражение — FlipHorizontal2, оттенок — Palette, размытие — Droplets, резкость — Focus, удаление фона — Scissors, анализ — Info и так далее; для семейств конвертаций — FileInput/FileOutput варианты.
Иконка появляется в трёх местах: карточка выдачи, карточка каталога `/list-tools`, легенда шага цепочки («Шаг N: [icon] Название»).
## 4. Карточки выдачи
- Сетка: одна колонка на узком экране, две — начиная со среднего героя.
- Композиция карточки: квадрат иконки слева, справа название (полужирное) и однострочное описание с обрезкой.
- Состояния hover и активной с клавиатуры — тот же акцент подсветки, что сейчас.
- Сортировка выдачи: скоринг запроса ↓, затем популярность ↓, затем название.
## 5. Этапы и проверки
- **A. Реестр** — поля `popularity` и `icon`, словарь иконок на все 51, расширение тестов целостности. Проверка: `pnpm test/check`.
- **B. Карточки поиска** — сетка выдачи с иконками и учётом популярности, клавиатура без изменений. Проверка: визуально, `check/build`.
- **C. Распространение** — иконки в каталоге `/list-tools` и легендах звеньев. Проверка: визуально, `build`.
- **D. Итог** — полный прогон и смоук §8.
## 6. Смоук-чеклист
1. Пустой запрос на герое показывает популярные первыми после первого символа.
2. Запрос «обрез» поднимает карточку обрезки выше прочих совпадений.
3. У каждой карточки есть иконка; у большинства — разные, соответствующие смыслу.
4. Наведение и стрелки подсвечивают карточку; Enter открывает инструмент.
5. Открытый инструмент работает как раньше: параметры в колонках, результат, скачивание.
6. Цепочка: легенда каждого шага показывает иконку выбранного инструмента.
7. `/list-tools`: карточки категорий с иконками, ссылки работают.
8. Регресс: старые сценарии (загрузка файла, цепочка, скачивание) не изменились.
## 7. Критерии готовности
- Все инструменты имеют иконку и ненулевую популярность; тесты это гарантируют.
- Выдача поиска — сетка карточек с корректной сортировкой и живой клавиатурой.
- Иконки присутствуют в поиске, каталоге и легендах шагов.
- `test/check/build` зелёные, смоук §6 пройден.
## 8. Что сознательно не делаем
- Автоматический расчёт популярности из статистики использования — значения ручные.
- Собственный набор SVG-иконок — берём готовый открытый.
- Переводы названий и поиск на других языках — отдельная i18n-волна.
- Изображения-превью в карточках поиска — только иконка и текст.
+66
View File
@@ -0,0 +1,66 @@
# План: MEDIUM-волна №1 — удаление фона, размытие, резкость + Web Worker
> **СТАТУС: ВЫПОЛНЕН 2026-08-23.**
## 1. Зачем
- Первые операции настоящего алгоритмического уровня: свёртки и заливка связной области.
- Витринная фича каталога — удаление однотонного фона без нейросетей: порог по цвету + заливка от краёв изображения + сглаживание границы маски.
- Эти операции реально блокируют UI-поток на больших картинках — повод включить давно подготовленный путь: исполнитель `executeStep` переезжает в Web Worker, страницы не меняются вовсе.
## 2. Инструменты — 3
- **Удалить фон PNG** (`remove-background-png`, категория «Прозрачность») — параметры: цвет фона, допуск похожести %, режим «только внешние области» (заливка от краёв вместо глобального совпадения), сглаживание границы (проходы мажоритарного фильтра маски). Даёт превью маски через существующий контракт `preview`.
- **Размытие PNG** (`blur-png`, новая категория «Фильтры») — гауссово приближение тремя проходами разделяемого бокса; радиус слайдером 1–32 px.
- **Резкость PNG** (`sharpen-png`, «Фильтры») — свёртка ядром резкости с регулируемой силой (смесь исходного и обработанного), сила слайдером 0–100 %.
## 3. Новое ядро (чистые функции, детерминированные, покрытые тестами)
- `core/convolution.ts`
- универсальная свёртка с клампом краёв;
- разделяемый бокс-блюр и сборка гауссова приближения из трёх проходов;
- ядро резкости и смешивание с оригиналом по силе.
- `core/background.ts`
- построение маски удаления по цветовому расстоянию (та же метрика, что у удаления цвета);
- заливка связной области от всех граничных пикселей (очередь, без рекурсии);
- мажоритарное сглаживание маски заданным числом проходов;
- сборка результата: альфа 0 у удалённых пикселей; плюс функция маски для превью.
## 4. Web Worker
- Точка исполнения одна — `tools/executor.ts`; меняется только её внутренность.
- Воркер-файл импортирует реестр и исполняет операцию по `toolId`; картинка приходит копией (структурный клон), результат возвращается передаваемым буфером.
- Ленивая инициализация воркера при первом вызове; если воркер недоступен (старый браузер/тестовая среда) — автоматический откат на прямое исполнение в главном потоке. Обе ветви обязаны давать побайтово одинаковый результат.
- Генераторы и текстовые представления остаются в главном потоке — они мгновенные.
- Пипетка и лупа работают с уже готовыми картинками и не зависят от переноса.
## 5. Этапы и проверки
- **A. Ядро** — свёртки и фон с тестами на малых матрицах (точные ожидания руками). Проверка: `pnpm test/check`.
- **B. Реестр** — категория «Фильтры», три записи, превью маски. Проверка: тесты целостности, визуально.
- **C. Web Worker** — перенос исполнителя, отккат-ветвь. Проверка: результаты до/после переключения идентичны байт-в-байт на фиксированной картинке и параметрах; `test/build` зелёные.
- **D. Итог** — полный прогон и смоук §8.
## 6. Смоук-чеклист
1. Размытие: радиус 1 и 32 — плавно усиливается, прозрачные края не темнеют.
2. Резкость: сила 0 не меняет картинку; сила 100 заметно подчёркивает края без цветового мусора.
3. Удаление фона на логотипе с однотонным фоном: фон исчез, содержимое цело.
4. Режим «только внешние области»: дырка внутри объекта того же цвета остаётся непрозрачной.
5. Сглаживание 0/2/4 прохода — граница от жёсткой к мягкой, результат воспроизводим.
6. Превью маски показывает белым удаляемую область; скачивание всегда отдаёт результат.
7. Во время размытия большой картинки интерфейс остаётся отзывчивым (чип «Пересчёт…», скролл живой).
8. Цепочка: размытие → резкость → удаление фона работает; каждый блок скачивает свой промежуток.
9. Регресс старых инструментов и главной-поиска без изменений поведения.
## 7. Критерии готовности
- Три новых инструмента проходят смоук §6; операции ядра покрыты точными юнит-тестами.
- Исполнение изображений идёт через воркер там, где он доступен, с гарантированно одинаковым результатом обеих ветвей.
- `test/check/build` зелёные.
## 8. Что сознательно не делаем
- Многопроходное гауссово ядро «по-настоящему» (O(r²)) — трёхпроходного боксового приближения достаточно визуально и оно быстрее.
- Хромакей по нескольким цветам, ИИ-сегментация — следующие волны.
- Отмена выполняющегося прогона воркера — устаревшие результаты отбрасываются токеном, сама работа завершается фоном.
+75
View File
@@ -0,0 +1,75 @@
# План: главная страница как рабочая область с цепочкой
> **СТАТУС: ВЫПОЛНЕН 2026-08-23.** Заменяет идею отдельной страницы рабочего пространства.
## 1. Модель
1. **Главная страница — это рабочая область.** Без выбранного инструмента на ней большой поиск: начните вводить название — список отфильтровывается; выбор разворачивает инструмент прямо здесь, без перехода.
2. Развернутый инструмент выглядит как привычная страница: слева исходник, справа результат со своей кнопкой скачивания, параметры — в отдельной карточке под сценой.
3. Под результатом рядом с кнопкой скачивания — кнопка **«⛓ Chain with…»**: добавляет пустую секцию, внутри которой такой же поиск; выбор заполняет секцию следующим инструментом. Так цепочка растёт сколько угодно.
4. Каждая заполненная секция цепи — полноценная: свои параметры, своё превью, своя кнопка скачивания промежуточного результата. Крестик убирает секцию; кнопка «✂ Remove chain» снимает всю цепь разом (без подтверждения).
5. Чекбокс «Показать маску» живёт в карточке параметров базового инструмента и влияет только на его превью: в цепь всегда передаётся сам результат.
6. Сетка-каталог инструментов переезжает на отдельную страницу `/list-tools` — для SEO и визуального поиска. Основная работа происходит на главной; в шапке появляются ссылки на обе.
7. Прямые адреса `/tools/<id>` сохраняются: тот же развернутый инструмент для глубоких ссылок.
8. Цепочка переживает перезагрузку (автосохранение в localStorage). Экспорт/импорт файлом отложен до появления CLI/wasm-части.
## 2. Архитектура
- Страница инструмента уже собрана как самодостаточный компонент, принимающий запись реестра, — он и становится встраиваемым блоком. Маршруты `/tools/[id]` и главная используют один и тот же компонент: первая — по адресу из URL, вторая — по выбору из поиска.
- Состояние главной: выбран ли инструмент; если да — рендерится блок инструмента, целиком владеющий своим исходником, параметрами, результатом и цепочкой.
- Смена инструмента на главной (через повторный поиск) сбрасывает блок целиком.
- Цепочка хранится массивом шагов; у пустого звена инструмент ещё не выбран — такие звенья не сохраняются в localStorage и не участвуют в прогоне.
- Исполнение — последовательная свёртка: выход каждого звена — вход следующего; результаты всех звеньев доступны их секциям. Общий авто-раннер даёт токен отмены и задержку ~300 мс.
## 3. Переиспользование и доработки существующего
Готовое берётся как есть: `SourceCard`, `Preview`, `ParamForm`, `DownloadButton`, `EmptyState`, модуль пайплайна, авто-раннер, `PNG_OUTPUT`.
Доработки:
- **Блок страницы инструмента** — расширяется секцией цепочки: список заполненных звеньев, каждое со своими параметрами, превью и скачиванием, плюс пустые звенья с поиском. Кнопки «⛓» и «✂ Remove chain» — рядом со скачиванием базового результата.
- **`ParamsCard`** — опциональный блок «Показать маску» (флаг preview у инструмента + двусторонний флаг).
- **Карточка результата** — упрощается: получает готовую картинку, маска уходит в карточку параметров.
- **Существующая главная-каталог** — переезжает на маршрут `/list-tools` почти без изменений.
Удаляется: черновой маршрут рабочего пространства и его компоненты списка/селектора — вместо них новые, под вертикальную модель.
## 4. Новые компоненты
- **`ToolSearch.svelte`** — поиск с нечётким совпадением: поле ввода, отфильтрованный список (регистронезависимая подпоследовательность по названию и описанию, приоритет совпадению с начала), клавиатура ↑↓ Enter Esc; два размера — герой на главной и компактный внутри пустого звена. Предлагает только инструменты-трансформеры.
- **`ChainStep.svelte`** — секция одного звена: пустое звено показывает компактный поиск; заполненное — заголовок с номером и крестиком, форму параметров, превью результата, кнопку скачивания.
- **`ChainBar.svelte`** — строка под результатом базового инструмента: «⛓ Chain with…» и «✂ Remove chain» (видна только при непустой цепи).
## 5. Этапы и проверки
- **A. Поиск и главная** — `ToolSearch`, герой поиска на главной, встраивание блока инструмента, перенос каталога на `/list-tools`, ссылки в шапке. Проверка: `check/build`, визуально.
- **B. Цепочка** — состояние цепи в блоке инструмента, пустые звенья с поиском, прогон, промежуточные превью и скачивания, ошибки звеньев. Проверка: смоук §8 пп. 1–6.
- **C. Маска и упрощение** — блок маски в `ParamsCard`, упрощение карточки результата. Проверка: смоук §8 п. 7.
- **D. Хранение и итог** — автосохранение и восстановление цепочки, удаление чернового маршрута рабочего пространства, полный регресс каталога, `test/check/build`.
## 6. Смоук-чеклист
1. На главной — большое поле поиска; ввод фильтрует список по мере набора; Enter выбирает подсвеченный.
2. Выбранный инструмент разворачивается на той же странице: исходник, параметры, результат.
3. «⛓ Chain with…» добавляет пустую секцию с компактным поиском; выбор заполняет её.
4. У каждой секции своё превью и своё скачивание промежуточного результата.
5. Крестик убирает секцию; «✂ Remove chain» снимает все звенья разом.
6. Ошибка звена — баннер «Шаг N (имя): …»; последние успешные результаты секций не исчезают.
7. «Показать маску» переключает превью базового инструмента; в цепь идёт результат, скачивание базового отдаёт результат, не маску.
8. Перезагрузка восстанавливает цепочку заполненных звеньев; пустые звенья при этом исчезают.
9. Быстрые правки подряд — один финальный пересчёт всей вертикали.
10. `/list-tools` открывает старый каталог по категориям; прямые адреса `/tools/<id>` работают.
## 7. Критерии готовности
- Работа начинается с главной: поиск → инструмент → цепочка звеньями, каждый со своим превью и скачиванием.
- Отдельной страницы рабочего пространства нет; каталог доступен на `/list-tools`; прямые ссылки инструментов работают.
- Пустые звенья не попадают в сохранение и не ломают прогон.
- `test/check/build` зелёные, смоук §6 пройден.
## 8. Что сознательно не делаем
- Ветвления и прицепывание от середины цепи — новое звено всегда добавляется в конец.
- Пипетка и маски внутри звеньев цепи — они остаются фичей базового инструмента.
- Экспорт и импорт цепочки файлом — отложено до появления CLI/wasm-части.
- Несколько параллельных инструментов на главной одновременно — один активный блок за раз.
+20
View File
@@ -1 +1,21 @@
# Backlog
## 1. Несколько языков — оценка: L, разбита на подзадачи
### 1a. Инфраструктура i18n — M
Модуль словарей (`locales/ru.ts`, `locales/en.ts`), функция перевода с фолбэком на базовый язык, состояние активной локали (localStorage), переключатель в шапке, обновление `<html lang>`.
### 1b. Перевод каркаса интерфейса — S/M
Шапка, кнопки, подписи полей и состояний («Результат появится здесь», тексты ошибок исполнителя), главная-герой, страница каталога.
### 1c. Перевод контента реестра — L по объёму, механическая
Вынести title/description инструментов и label/options параметров в словари локалей с фолбэком. Объём: ~51 инструмент плюс параметры — основной трудозатратный кусок волны.
### 1d. Поиск по любому языку — M
Матчинг запроса против названий/описаний во всех поддерживаемых локалях сразу (не только активной); нормализация: нижний регистр, ё→е, снятие диакритики (NFD). Скоринг существующий сохраняется, лучшая локаль даёт балл.
Транслитерация ru↔en наизусть — не входит, отдельно при необходимости.
+71
View File
@@ -0,0 +1,71 @@
# План: MEDIUM-волна №2 — морфология прозрачности
> Статус: план к выполнению.
## 1. Зачем
Одна пара функций — расширение и сужение непрозрачных областей — открывает целое семейство пользовательских операций: обводка логотипов и иконок, контуры, утолщение и утончение подписей, удаление мелкого мусора и закрытие дырок. Всё это частые запросы в наборах PNG-утилит, а ядро получается компактным и легко покрывается тестами.
## 2. Инструменты — 7 (все в категории «Прозрачность»)
- **Обвести PNG** (`add-stroke-png`) — кольцо выбранного цвета вокруг непрозрачного содержимого; параметры: цвет, толщина 1–10 px.
- **Найти контур PNG** (`find-contour-png`) — линия по краю непрозрачных областей; параметр: толщина линии.
- **Утолщить PNG** (`make-thicker-png`) — расширение содержимого на N пикселей.
- **Утончить PNG** (`make-thinner-png`) — сужение содержимого на N пикселей.
- **Жёсткие края PNG** (`harden-alpha-png`) — бинаризация альфы по порогу: полупрозрачность уходит, остаётся два состояния.
- **Убрать мусор PNG** (`despeckle-alpha-png`) — открытие: удаляет одиночные полупрозрачные пиксели и мелкие крапинки.
- **Закрыть дыры PNG** (`close-holes-png`) — закрытие: заполняет одиночные прозрачные точки внутри объекта.
Все операции применяются к альфа-каналу; RGB пикселей сохраняется.
## 3. Ядро `core/morphology.ts`
Чистые функции над бинарной маской и над альфа-каналом:
- `buildAlphaMask(img)` — 1 там, где альфа > 0.
- `dilateMask(mask, w, h, radius, shape)` — расширение; `shape`: квадрат или диск (для обводок диск даёт ровную круглую линию).
- `erodeMask(...)` — зеркальная операция.
- `dilateImage / erodeImage(img, radiusPx)` — применяют маскирование к альфе изображения, RGB сохраняется.
- Сборщики:
- обводка: `dilate(mask) mask` → новое кольцо заливается цветом;
- контур: `mask erode(mask)` → линия;
- открытие: `erode → dilate`; закрытие: `dilate → erode`.
- `hardenAlpha(img, thresholdPercent)` — отдельно от морфологии, но той же волной.
Структурирующий элемент при радиусе r — диск радиуса r (расстояние до центра ≤ r); квадрат не используем, чтобы углы обводок не были угловатыми.
## 4. Web Worker
Ничего не требуется: новые операции попадают в реестр, исполнитель автоматически исполняет их в воркере. Тесты ядра идут мимо воркера (чистые функции).
## 5. Этапы и проверки
- **A. Ядро** — `morphology.ts` + точные тесты на матрицах 5×5: расширение диска, сужение, открытие убирает одиночный пиксель, закрытие закрывает одиночную дыру, кольцо обводки нужного цвета и толщины. Проверка: `pnpm test/check`.
- **B. Реестр** — семь записей, тест целостности обновляется (51 → 58). Проверка: `test/build`, визуально.
- **C. Итог** — полный прогон, смоук §7.
## 6. Критерии готовности
- Семь новых инструментов работают на прозрачных PNG: обводка, контур, толще/тоньше, жёсткие края, убрать мусор, закрыть дыры.
- Все операции детерминированы и покрыты юнит-тестами с ручными ожиданиями.
- Исполнение автоматически идёт в воркере.
- `test/check/build` зелёные, смоук §7 пройден.
## 7. Смоук-чеклист
1. Обводка толщиной 3 вокруг круглого стикера — ровное кольцо выбранного цвета, без угловатости.
2. Толщина 1 — тонкая аккуратная линия; 10 — толстая, форма содержимого не искажается сверх роста.
3. Контур показывает только край содержимого линией.
4. Толще/тоньше на тонкой надписи — штрихи растут/сжимаются без разрывов при умеренных значениях.
5. Жёсткие края: полупрозрачность исчезает, силуэт сохраняется.
6. Убрать мусор убирает одиночные точки-крапинки, не трогая крупные области.
7. Закрыть дыры заполняет одиночные прозрачные точки внутри логотипа.
8. Цепочка: обводка после удаления фона работает; каждое звено скачивает свой промежуток.
9. Регресс остальных инструментов без изменений.
## 8. Что сознательно не делаем
- Морфология по яркости для непрозрачных сканов — волна фокусируется на альфе прозрачных PNG.
- Выбор структурирующего элемента пользователем — всегда диск.
- Feather/свечение краёв — отдельная будущая задача поверх размытия.
- Произвольные SVG-маски и градиентные обводки.
+4
View File
@@ -8,6 +8,10 @@
- **Пайплайн = JSON-список шагов** `{tool, params}`. Формат закладывается сейчас и потом станет общим для браузера, wasm и CLI.
- **Реестр инструментов** (id, title, description, схема параметров, `run`) — источник истины: из него генерируются страницы, формы, пайплайны. Позже — общий формат для Rust-парсера.
- Валидация будущего Rust-порта — эталонные файлы, сгенерированные из TS-реализации. Сравнение: **бит-в-бит** для чистых пиксельных операций, **perceptual diff** для canvas-зависимых.
- **Единая точка исполнения инструментов.** Страницы вызывают операции только через `web/src/lib/tools/executor.ts::executeStep` — нигде напрямую `tool.run`. Контракт уже асинхронный (`Promise<PixelImage>`), поэтому:
- когда появятся тяжёлые MEDIUM-операции (свёртки, квантование), внутренности `executeStep` переезжают в Web Worker — воркер импортирует тот же чистый core как TS-фолбэк, алгоритмы не дублируются, вызывающий код не меняется;
- в WASM-фазе тот же воркер принимает wasm-модуль и диспетчеризует по `toolId` — свитч A/B из фазы 5 сводится к замене реализации внутри исполнителя;
- до появления MEDIUM-операций воркер не вводится: EASY-операции быстры и не блокируют UI.
## 1. Структура репозитория (этап TS)
+2 -1
View File
@@ -5,7 +5,8 @@
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"dev": "pnpm --dir web dev"
"dev": "pnpm --dir web dev",
"build": "pnpm --dir web build"
},
"keywords": [],
"author": "Ku6epXBOCTuK",
+3
View File
@@ -21,5 +21,8 @@
"typescript": "^6.0.3",
"vite": "^8.0.16",
"vitest": "^4.1.11"
},
"dependencies": {
"@lucide/svelte": "^1.33.0"
}
}
+13
View File
@@ -7,6 +7,10 @@ settings:
importers:
.:
dependencies:
'@lucide/svelte':
specifier: ^1.33.0
version: 1.33.0(svelte@5.56.9)
devDependencies:
'@sveltejs/adapter-static':
specifier: ^3.0.10
@@ -51,6 +55,11 @@ packages:
'@jridgewell/trace-mapping@0.3.31':
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
'@lucide/svelte@1.33.0':
resolution: {integrity: sha512-b+osTYG2V4dge5Lr7tnaTaahhy/4vH7Xb8zcqVjmctjwgkpXS0NNOJPkGzHHZoxtw/OO+2YAU28omeMfYCaawg==}
peerDependencies:
svelte: ^5
'@oxc-project/types@0.144.0':
resolution: {integrity: sha512-nuhZIOLuI6TFQ32I/WnUx+SCPY7SdSKwgnFHydAuoS1+Z4BRcaP+RRJmGzl9lw+0OFF7UmaESf7KQRXaNLHypg==}
@@ -627,6 +636,10 @@ snapshots:
'@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.5.5
'@lucide/svelte@1.33.0(svelte@5.56.9)':
dependencies:
svelte: 5.56.9
'@oxc-project/types@0.144.0': {}
'@polka/url@1.0.0-next.29': {}
+89
View File
@@ -154,3 +154,92 @@ input.control[type='number'] {
white-space: nowrap;
border: 0;
}
.tool-stage {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--space-4);
padding: var(--space-4);
align-items: start;
width: 100%;
}
.tool-stage.single {
grid-template-columns: 1fr;
}
.tool-stage .cell {
min-width: 0;
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.tool-stage .cell + .cell {
border-left: 1px solid var(--border);
padding-left: var(--space-4);
}
.cell-media {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 14rem;
position: relative;
width: 100%;
}
.chain-stack {
display: flex;
flex-direction: column;
gap: var(--space-3);
margin-top: var(--space-4);
}
.edge-legend {
position: absolute;
top: -0.65em;
padding: 0 var(--space-2);
background: var(--surface);
color: var(--text-muted);
font-size: var(--text-xs);
text-transform: uppercase;
letter-spacing: 0.04em;
line-height: 1.3;
white-space: nowrap;
z-index: 1;
}
.params-sep {
position: relative;
border-top: 1px solid var(--border);
margin: 0 var(--space-4);
padding-bottom: var(--space-3);
}
.actions-row {
display: flex;
gap: var(--space-1);
align-items: stretch;
width: 100%;
}
.actions-row > button {
flex: 1;
min-width: 0;
}
@media (max-width: 48rem) {
.tool-stage {
grid-template-columns: 1fr;
}
.tool-stage .cell + .cell {
border-left: none;
padding-left: 0;
border-top: 1px solid var(--border);
padding-top: var(--space-4);
}
}
+11 -2
View File
@@ -1,4 +1,11 @@
export type CategoryId = 'convert' | 'alpha' | 'color' | 'geometry' | 'analyze';
export type CategoryId =
| 'convert'
| 'alpha'
| 'color'
| 'geometry'
| 'analyze'
| 'generate'
| 'filters';
export type Category = { id: CategoryId; label: string };
@@ -7,5 +14,7 @@ export const CATEGORIES: Category[] = [
{ id: 'alpha', label: 'Прозрачность' },
{ id: 'color', label: 'Цвет' },
{ id: 'geometry', label: 'Геометрия' },
{ id: 'analyze', label: 'Анализ' }
{ id: 'filters', label: 'Фильтры' },
{ id: 'analyze', label: 'Анализ' },
{ id: 'generate', label: 'Генерация' }
];
+77 -43
View File
@@ -11,50 +11,84 @@
values: Record<string, any>;
pipetteTargetId?: string | null;
onPipetteToggle?: (id: string) => void;
hasMask?: boolean;
showMask?: boolean;
}
let { params, values = $bindable(), pipetteTargetId = null, onPipetteToggle }: Props = $props();
let {
params,
values = $bindable(),
pipetteTargetId = null,
onPipetteToggle,
hasMask = false,
showMask = $bindable(false)
}: Props = $props();
</script>
{#each params as param (param.id)}
<div class="field">
{#if param.type === 'checkbox'}
<CheckboxField id={param.id} label={param.label} bind:checked={values[param.id]} />
{:else if param.type === 'number'}
<TextField
id={param.id}
label={param.label}
type="number"
min={param.min}
max={param.max}
step={param.step}
bind:value={values[param.id]}
/>
{:else if param.type === 'slider'}
<SliderField
id={param.id}
label={param.label}
min={param.min}
max={param.max}
step={param.step}
default={param.default}
bind:value={values[param.id]}
/>
{:else if param.type === 'select'}
<SelectField
id={param.id}
label={param.label}
options={param.options}
bind:value={values[param.id]}
/>
{:else if param.type === 'color'}
<ColorField
id={param.id}
label={param.label}
bind:value={values[param.id]}
pipetteActive={pipetteTargetId === param.id}
onPipetteToggle={() => onPipetteToggle?.(param.id)}
/>
{/if}
</div>
{/each}
<div class="params-grid">
{#if hasMask}
<CheckboxField id="show-mask" label="Показать маску" bind:checked={showMask} />
{/if}
{#each params as param (param.id)}
<div class="field">
{#if param.type === 'checkbox'}
<CheckboxField id={param.id} label={param.label} bind:checked={values[param.id]} />
{:else if param.type === 'number'}
<TextField
id={param.id}
label={param.label}
type="number"
min={param.min}
max={param.max}
step={param.step}
bind:value={values[param.id]}
/>
{:else if param.type === 'slider'}
<SliderField
id={param.id}
label={param.label}
min={param.min}
max={param.max}
step={param.step}
default={param.default}
bind:value={values[param.id]}
/>
{:else if param.type === 'select'}
<SelectField
id={param.id}
label={param.label}
options={param.options}
bind:value={values[param.id]}
/>
{:else if param.type === 'color'}
<ColorField
id={param.id}
label={param.label}
bind:value={values[param.id]}
pipetteActive={pipetteTargetId === param.id}
onPipetteToggle={() => onPipetteToggle?.(param.id)}
/>
{/if}
</div>
{/each}
</div>
<style>
.params-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
column-gap: var(--space-5);
}
@media (min-width: 75rem) {
.params-grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}
@media (max-width: 50rem) {
.params-grid {
grid-template-columns: 1fr;
}
}
</style>
-1
View File
@@ -94,7 +94,6 @@
repeating-conic-gradient(var(--check-a) 0% 25%, var(--check-b) 0% 50%);
background-size: 16px 16px;
border: 1px solid var(--border);
border-radius: var(--radius-m);
}
canvas.pipette {
+204 -56
View File
@@ -2,13 +2,21 @@
import { imageInfo, type ImageInfo } from '$lib/core/analyze';
import { decodeFile, isSupportedImage, unsupportedImageMessage } from '$lib/core/io';
import type { PixelImage } from '$lib/core/types';
import { defaultParams, sanitizeParams, type ToolEntry } from '$lib/registry';
import { defaultParams, getTool, outputOf, sanitizeParams, type ToolEntry } from '$lib/registry';
import { loadStoredSteps, newStepId, saveSteps, type PipelineStep } from '$lib/tools/pipeline';
import DownloadButton from './DownloadButton.svelte';
import ParamForm from './ParamForm.svelte';
import Preview from './Preview.svelte';
import ToolSearch from './search/ToolSearch.svelte';
import ChainToolBlock from './chain/ChainToolBlock.svelte';
import { createAutoRunner } from '$lib/tools/auto-run';
import { executeStep } from '$lib/tools/executor';
import ParamsCard from './tool/ParamsCard.svelte';
import ResultCard from './tool/ResultCard.svelte';
import SourceCard from './tool/SourceCard.svelte';
import TextInputCard from './tool/TextInputCard.svelte';
let { tool }: { tool: ToolEntry } = $props();
let { tool, restoreChain = false }: { tool: ToolEntry; restoreChain?: boolean } = $props();
type Status = 'idle' | 'loaded' | 'processing' | 'error';
@@ -21,13 +29,52 @@
let info = $state<ImageInfo | null>(null);
let errorText = $state('');
let values = $state<Record<string, any>>({});
// svelte-ignore state_referenced_locally
let chain = $state<PipelineStep[]>(restoreChain ? loadStoredSteps() : []);
let chainResults = $state<(PixelImage | null)[]>([]);
let lastRunChainJson = '';
const isInfo = $derived(tool.resultType === 'info');
const isSourceless = $derived(tool.sourceMode === 'none');
const isTextSource = $derived(tool.sourceMode === 'text');
const sanitized = $derived(sanitizeParams(tool, values));
const canChainBase = $derived(
(tool.resultType ?? 'image') === 'image' && tool.sourceMode !== 'text'
);
const hasMask = $derived(typeof tool.preview === 'function');
const shownBase = $derived(showMask && previewResult ? previewResult : result);
const hasFilledSteps = $derived(chain.some((step) => step.toolId !== ''));
let runToken = 0;
function addChainStep() {
chain.push({ id: newStepId(), toolId: '', values: {} });
}
function removeChainStep(index: number) {
chain.splice(index, 1);
chainResults = [];
}
function removeChain() {
chain.length = 0;
chainResults = [];
}
function toggleChain() {
if (chain.length > 0) {
removeChain();
} else {
addChainStep();
}
}
function applyChainTool(index: number, toolId: string) {
const stepTool = getTool(toolId);
if (!stepTool || !chain[index]) return;
chain[index].toolId = toolId;
chain[index].values = defaultParams(stepTool);
}
const runner = createAutoRunner();
let hasLastRun = false;
let lastRunSource: PixelImage | null = null;
let lastRunValuesJson = '';
@@ -86,10 +133,11 @@
async function runTool() {
if (isInfo) return;
if (!source && !isSourceless) return;
const token = ++runToken;
const token = runner.next();
hasLastRun = true;
lastRunSource = source;
lastRunValuesJson = JSON.stringify(sanitized);
lastRunChainJson = JSON.stringify(chain);
try {
let next: PixelImage | null = null;
let nextText: string | null = null;
@@ -99,9 +147,8 @@
} else if (isSourceless) {
next = await tool.generate!(sanitized);
} else {
next = await tool.run!(source!, sanitized);
next = await executeStep(tool, source!, sanitized);
}
let nextPreview: PixelImage | null = null;
if (tool.preview && source) {
try {
@@ -111,24 +158,57 @@
}
}
if (token !== runToken) return;
if (!runner.isCurrent(token)) return;
result = next;
previewResult = nextPreview;
textResult = nextText;
const collected: (PixelImage | null)[] = [];
let current: PixelImage | null = next ?? source;
for (let i = 0; i < chain.length; i++) {
const step = chain[i];
const stepTool = step.toolId === '' ? undefined : getTool(step.toolId);
if (!current || !stepTool?.run) {
collected.push(null);
continue;
}
try {
current = await executeStep(stepTool, current, sanitizeParams(stepTool, step.values));
} catch (e) {
const message = e instanceof Error ? e.message : String(e);
throw new Error(`Шаг ${i + 1} (${stepTool.title}): ${message}`);
}
if (!runner.isCurrent(token)) return;
collected.push(current);
}
chainResults = collected;
status = 'loaded';
} catch (e) {
if (token !== runToken) return;
if (!runner.isCurrent(token)) return;
showError(e);
}
}
$effect(() => {
const valuesJson = JSON.stringify(sanitized);
if (hasLastRun && source === lastRunSource && valuesJson === lastRunValuesJson) return;
const chainJson = JSON.stringify(chain);
if (
hasLastRun &&
source === lastRunSource &&
valuesJson === lastRunValuesJson &&
chainJson === lastRunChainJson
) {
return;
}
if (!source && !isSourceless) return;
if (isInfo) return;
const timer = setTimeout(() => void runTool(), 300);
return () => clearTimeout(timer);
return runner.schedule(() => void runTool());
});
$effect(() => {
const filled = chain.filter((step) => step.toolId !== '');
if (filled.length === 0 && !hasLastRun) return;
saveSteps(filled);
});
function showError(e: unknown) {
@@ -178,48 +258,96 @@
<div class="error-banner" role="alert">{errorText}</div>
{/if}
<div class="stage panel" class:single={isSourceless}>
{#if !isSourceless}
<div class="panel tool-block">
<div class="tool-stage" class:single={isSourceless}>
{#if !isSourceless}
<span class="edge-legend source-legend" aria-hidden="true">Исходник</span>
<div class="cell">
{#if isTextSource && !source}
<TextInputCard onSubmit={handleTextSubmit} />
{:else}
<SourceCard
{source}
onFile={handleFile}
onError={(message) => (errorText = message)}
onReset={reset}
pipetteActive={!!pipetteTargetId}
onPickColor={handlePickColor}
/>
{/if}
</div>
{/if}
<span class="edge-legend result-legend" aria-hidden="true">
{isInfo ? 'Сводка' : 'Результат'}
</span>
<div class="cell">
{#if isTextSource && !source}
<TextInputCard onSubmit={handleTextSubmit} />
{:else}
<SourceCard
{source}
onFile={handleFile}
onError={(message) => (errorText = message)}
onReset={reset}
pipetteActive={!!pipetteTargetId}
onPickColor={handlePickColor}
/>
{/if}
</div>
{/if}
<div class="cell">
<ResultCard
{tool}
sourceLoaded={isSourceless ? true : !!source}
{status}
{result}
{previewResult}
bind:showMask
displayImage={shownBase}
{info}
{isInfo}
params={sanitized}
{textResult}
textResult={textResult}
onChainToggle={canChainBase ? toggleChain : undefined}
hasChain={chain.length > 0}
onDownloadError={showError}
/>
</div>
</div>
{#if (source || isSourceless) && !isInfo}
{#if (source || isSourceless) && !isInfo && (tool.params.length > 0 || hasMask)}
<div class="params-sep">
<span class="edge-legend" aria-hidden="true">Параметры</span>
</div>
<ParamsCard
params={tool.params}
bind:values
{pipetteTargetId}
onPipetteToggle={handlePipetteToggle}
hasMask={hasMask}
bind:showMask
/>
{/if}
</div>
<div class="chain-stack">
{#each chain as step, index (step.id)}
{#if step.toolId === ''}
<div class="panel empty-slot">
<header>
<h3 class="heading-section">Шаг {index + 1}</h3>
<button
type="button"
class="remove-step"
aria-label="Убрать шаг"
onclick={() => removeChainStep(index)}
>
</button>
</header>
<ToolSearch onSelect={(id) => applyChainTool(index, id)} />
</div>
{:else if getTool(step.toolId)}
{@const stepTool = getTool(step.toolId)!}
<ChainToolBlock
index={index}
tool={stepTool}
bind:values={step.values}
input={index === 0 ? result : (chainResults[index - 1] ?? null)}
result={chainResults[index] ?? null}
busy={status === 'processing'}
isLast={index === chain.length - 1}
onRemove={() => removeChainStep(index)}
onError={showError}
onAddStep={addChainStep}
onRemoveChain={removeChain}
/>
{/if}
{/each}
</div>
</section>
<style>
@@ -236,37 +364,57 @@
margin-bottom: var(--space-3);
}
.stage {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--space-4);
padding: var(--space-4);
.tool-stage {
position: relative;
}
.stage.single {
grid-template-columns: 1fr;
.source-legend {
left: 25%;
transform: translateX(-50%);
}
.cell {
min-width: 0;
display: flex;
}
.cell + .cell {
border-left: 1px solid var(--border);
padding-left: var(--space-4);
.result-legend {
left: 75%;
transform: translateX(-50%);
}
@media (max-width: 48rem) {
.stage {
grid-template-columns: 1fr;
}
.cell + .cell {
border-left: none;
padding-left: 0;
border-top: 1px solid var(--border);
padding-top: var(--space-4);
.source-legend,
.result-legend {
display: none;
}
}
.empty-slot {
padding: var(--space-3);
}
.empty-slot header {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-2);
margin-bottom: var(--space-2);
}
.empty-slot h3 {
margin: 0;
}
.remove-step {
width: 1.6rem;
height: 1.6rem;
padding: 0;
border: 1px solid var(--border);
border-radius: var(--radius-s);
background: var(--surface);
color: var(--text-muted);
line-height: 1;
cursor: pointer;
}
.remove-step:hover {
border-color: var(--danger);
color: var(--danger);
}
</style>
@@ -0,0 +1,177 @@
<script lang="ts">
import { outputOf, sanitizeParams, type ToolEntry } from '$lib/registry';
import type { PixelImage } from '$lib/core/types';
import DownloadButton from '../DownloadButton.svelte';
import Button from '../ui/Button.svelte';
import EmptyState from '../ui/EmptyState.svelte';
import ParamsCard from '../tool/ParamsCard.svelte';
import Preview from '../Preview.svelte';
import { TOOL_ICONS } from '$lib/tools/tool-icons';
interface Props {
index: number;
tool: ToolEntry;
values: Record<string, any>;
input: PixelImage | null;
result: PixelImage | null;
busy: boolean;
isLast: boolean;
onRemove: () => void;
onError: (e: unknown) => void;
onAddStep: () => void;
onRemoveChain: () => void;
}
let {
index,
tool,
values = $bindable(),
input,
result,
busy,
isLast,
onRemove,
onError,
onAddStep,
onRemoveChain
}: Props = $props();
const format = $derived(outputOf(tool));
const safeParams = $derived(sanitizeParams(tool, values));
const StepIcon = $derived(TOOL_ICONS[tool.id]);
</script>
<div class="block">
<div class="panel tool-stage">
<span class="edge-legend step-legend">
{#if StepIcon}
<span class="step-icon" aria-hidden="true"><StepIcon size={14} strokeWidth={2} /></span>
{/if}
Шаг {index + 1}: {tool.title}
<button
type="button"
class="remove"
aria-label="Убрать шаг"
title="Убрать шаг"
onclick={onRemove}
>
</button>
</span>
<div class="cell">
<span class="edge-legend cell-legend" aria-hidden="true">Вход</span>
<div class="cell-media">
<Preview image={input} />
</div>
</div>
<div class="cell">
<span class="edge-legend cell-legend" aria-hidden="true">Результат</span>
{#if busy && !result}
<div class="cell-media">
<EmptyState title="Обработка…" hint="Выполняется шаг цепочки" />
</div>
{:else}
<div class="cell-media">
<Preview image={result} />
{#if busy}
<span class="recalc" aria-live="polite">Пересчёт…</span>
{/if}
</div>
<div class="actions-row">
<DownloadButton
image={result}
format={format}
baseName="{index + 1}-{tool.id}"
params={safeParams}
{onError}
/>
<Button variant="secondary" fullWidth onclick={isLast ? onAddStep : onRemoveChain}>
{isLast ? '⛓ Следующий инструмент' : '✂ Оборвать цепочку'}
</Button>
</div>
{/if}
</div>
</div>
{#if tool.params.length > 0}
<div class="params-sep">
<span class="edge-legend" aria-hidden="true">Параметры</span>
</div>
<ParamsCard params={tool.params} bind:values />
{/if}
</div>
<style>
.block {
display: flex;
flex-direction: column;
}
.tool-stage {
position: relative;
padding-top: var(--space-3);
border-top: 0;
}
.step-legend {
left: var(--space-3);
display: inline-flex;
align-items: center;
gap: var(--space-2);
color: var(--text);
font-weight: 600;
}
.step-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.4rem;
height: 1.4rem;
border-radius: var(--radius-s);
background: color-mix(in srgb, var(--accent) 10%, var(--surface));
color: var(--accent);
}
.remove {
width: 1.4rem;
height: 1.4rem;
padding: 0;
border: 1px solid var(--border);
border-radius: var(--radius-s);
background: var(--surface);
color: var(--text-muted);
line-height: 1;
cursor: pointer;
transition:
border-color var(--transition-fast),
color var(--transition-fast);
}
.remove:hover {
border-color: var(--danger);
color: var(--danger);
}
.cell {
position: relative;
}
.cell-legend {
top: -0.9em;
left: 50%;
transform: translateX(-50%);
}
.recalc {
position: absolute;
top: var(--space-2);
right: var(--space-2);
padding: 2px var(--space-2);
border-radius: var(--radius-s);
background: color-mix(in srgb, var(--accent) 12%, var(--surface));
color: var(--accent);
font-size: var(--text-xs);
}
</style>
@@ -0,0 +1,114 @@
<script lang="ts">
import { TOOL_ICONS } from '$lib/tools/tool-icons';
interface Props {
toolId: string;
title: string;
description: string;
selected?: boolean;
href?: string;
onActivate?: () => void;
onHover?: () => void;
}
let {
toolId,
title,
description,
selected = false,
href,
onActivate,
onHover
}: Props = $props();
const Icon = $derived(TOOL_ICONS[toolId]);
</script>
{#snippet content()}
<span class="icon-box" aria-hidden="true">
{#if Icon}<Icon size={24} strokeWidth={1.75} />{/if}
</span>
<span class="text">
<span class="title">{title}</span>
<span class="hint text-caption text-muted">{description}</span>
</span>
{/snippet}
{#if href}
<a
{href}
class="card"
class:selected
onmousemove={onHover}
>
{@render content()}
</a>
{:else}
<div
class="card"
class:selected
role="option"
aria-selected={selected}
tabindex="-1"
onclick={onActivate}
onmousemove={onHover}
onkeydown={(e) => {
if (e.key === 'Enter') onActivate?.();
}}
>
{@render content()}
</div>
{/if}
<style>
.card {
display: flex;
align-items: flex-start;
gap: var(--space-2);
min-width: 0;
padding: var(--space-2);
border: 1px solid transparent;
border-radius: var(--radius-s);
cursor: pointer;
color: inherit;
text-decoration: none;
}
.card:hover,
.card.selected {
background: color-mix(in srgb, var(--accent) 10%, var(--surface));
border-color: color-mix(in srgb, var(--accent) 35%, var(--border));
}
.icon-box {
flex: none;
width: 2.6rem;
height: 2.6rem;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: var(--radius-s);
background: color-mix(in srgb, var(--accent) 10%, var(--surface));
color: var(--accent);
}
.text {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.title {
font-weight: 600;
font-size: var(--text-m);
}
.hint {
display: -webkit-box;
-webkit-line-clamp: 1;
line-clamp: 1;
-webkit-box-orient: vertical;
overflow: hidden;
}
</style>
@@ -0,0 +1,187 @@
<script lang="ts">
import { isChainable, TOOLS } from '$lib/registry';
import ToolCard from './ToolCard.svelte';
interface Props {
onSelect: (toolId: string) => void;
}
let { onSelect }: Props = $props();
const candidates = TOOLS.filter(isChainable);
let query = $state('');
let activeIndex = $state(0);
let listOpen = $state(false);
type Match = {
id: string;
title: string;
description: string;
popularity: number;
score: number;
};
function score(tool: (typeof TOOLS)[number], q: string): number | null {
if (q.length === 0) return 1;
const title = tool.title.toLowerCase();
const id = tool.id.toLowerCase();
const description = tool.description.toLowerCase();
if (title.startsWith(q)) return 100;
const inTitle = title.indexOf(q);
if (inTitle >= 0) return 80 - Math.min(inTitle, 40);
const inId = id.indexOf(q);
if (inId >= 0) return 60 - Math.min(inId, 40);
if (description.includes(q)) return 30;
let pos = 0;
let subScore = 20;
for (const ch of q) {
pos = title.indexOf(ch, pos);
if (pos < 0) {
subScore = -1;
break;
}
subScore -= 1;
pos += 1;
}
return subScore >= 0 ? subScore : null;
}
const matches = $derived.by(() => {
const q = query.trim().toLowerCase();
const found: Match[] = [];
for (const tool of candidates) {
const s = score(tool, q);
if (s !== null && s > 0) {
found.push({
id: tool.id,
title: tool.title,
description: tool.description,
popularity: tool.popularity ?? 50,
score: s
});
}
}
found.sort(
(a, b) =>
b.score - a.score ||
b.popularity - a.popularity ||
a.title.localeCompare(b.title)
);
return found.slice(0, 12);
});
function choose(id: string) {
listOpen = false;
query = '';
activeIndex = 0;
onSelect(id);
}
function onKeydown(event: KeyboardEvent) {
if (!listOpen || matches.length === 0) return;
if (event.key === 'ArrowDown') {
event.preventDefault();
activeIndex = (activeIndex + 1) % matches.length;
} else if (event.key === 'ArrowUp') {
event.preventDefault();
activeIndex = (activeIndex - 1 + matches.length) % matches.length;
} else if (event.key === 'Enter') {
event.preventDefault();
const match = matches[Math.min(activeIndex, matches.length - 1)];
if (match) choose(match.id);
} else if (event.key === 'Escape') {
listOpen = false;
}
}
</script>
<div class="search">
<input
type="search"
bind:value={query}
onfocus={() => (listOpen = true)}
oninput={() => {
listOpen = true;
activeIndex = 0;
}}
onkeydown={onKeydown}
placeholder="Найдите инструмент…"
aria-label="Поиск инструмента"
role="combobox"
aria-expanded={listOpen}
aria-controls="tool-search-list"
/>
{#if listOpen && query.trim().length > 0 && matches.length === 0}
<p class="none text-muted">Ничего не найдено — попробуйте другое слово.</p>
{:else if listOpen && matches.length > 0}
<div id="tool-search-list" class="cards" role="listbox">
{#each matches as match, index (match.id)}
<ToolCard
toolId={match.id}
title={match.title}
description={match.description}
selected={index === activeIndex}
onActivate={() => choose(match.id)}
onHover={() => (activeIndex = index)}
/>
{/each}
</div>
{/if}
</div>
<style>
.search {
position: relative;
width: 100%;
max-width: 36rem;
margin: 0 auto;
}
input {
width: 100%;
padding: var(--space-3) var(--space-4);
border: 1px solid var(--border);
border-radius: var(--radius-m);
background: var(--surface);
font-size: var(--text-xl);
text-align: center;
}
input:focus {
border-color: var(--accent);
outline: none;
}
.cards {
position: absolute;
left: 0;
right: 0;
top: calc(100% + var(--space-1));
z-index: 40;
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--space-1);
margin: 0;
padding: var(--space-1);
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-m);
box-shadow: var(--shadow-card);
max-height: 26rem;
overflow-y: auto;
text-align: left;
}
@media (max-width: 40rem) {
.cards {
grid-template-columns: 1fr;
}
}
.none {
margin: var(--space-1) 0 0;
text-align: center;
font-size: var(--text-s);
}
</style>
+21 -11
View File
@@ -7,15 +7,30 @@
values: Record<string, any>;
pipetteTargetId?: string | null;
onPipetteToggle?: (id: string) => void;
hasMask?: boolean;
showMask?: boolean;
}
let { params, values = $bindable(), pipetteTargetId = null, onPipetteToggle }: Props = $props();
let {
params,
values = $bindable(),
pipetteTargetId = null,
onPipetteToggle,
hasMask = false,
showMask = $bindable(false)
}: Props = $props();
</script>
<div class="panel root">
<h2 class="heading-section">Параметры</h2>
{#if params.length > 0}
<ParamForm {params} bind:values {pipetteTargetId} {onPipetteToggle} />
<div class="root">
{#if params.length > 0 || hasMask}
<ParamForm
{params}
bind:values
{pipetteTargetId}
{onPipetteToggle}
{hasMask}
bind:showMask
/>
{:else}
<p class="hint text-caption text-muted">
У этого инструмента нет параметров — результат уже готов.
@@ -25,12 +40,7 @@
<style>
.root {
margin-top: var(--space-4);
padding: var(--space-4);
}
h2 {
margin-bottom: var(--space-2);
padding: 0 var(--space-4) var(--space-3);
}
.hint {
+22 -24
View File
@@ -1,5 +1,5 @@
<script lang="ts">
import CheckboxField from '../ui/CheckboxField.svelte';
import Button from '../ui/Button.svelte';
import DownloadButton from '../DownloadButton.svelte';
import EmptyState from '../ui/EmptyState.svelte';
import InfoPanel from '../InfoPanel.svelte';
@@ -16,12 +16,13 @@
sourceLoaded: boolean;
status: Status;
result: PixelImage | null;
previewResult: PixelImage | null;
showMask: boolean;
displayImage: PixelImage | null;
info: ImageInfo | null;
isInfo: boolean;
params: Record<string, unknown>;
textResult: string | null;
onChainToggle?: () => void;
hasChain?: boolean;
onDownloadError: (e: unknown) => void;
}
@@ -30,21 +31,18 @@
sourceLoaded,
status,
result,
previewResult,
showMask = $bindable(false),
displayImage,
info,
isInfo,
params,
textResult,
onChainToggle,
hasChain = false,
onDownloadError
}: Props = $props();
const hasPreview = $derived(typeof tool.preview === 'function');
const shown = $derived(showMask && previewResult ? previewResult : result);
</script>
<div class="container">
<h2 class="heading-section">{isInfo ? 'Сводка' : 'Результат'}</h2>
{#if !sourceLoaded}
<div class="media">
<EmptyState
@@ -72,22 +70,26 @@
{/if}
</div>
{:else}
{#if hasPreview}
<CheckboxField id="show-mask" label="Показать маску" bind:checked={showMask} />
{/if}
<div class="media">
<Preview image={shown} />
<Preview image={displayImage} />
{#if status === 'processing'}
<span class="recalc" aria-live="polite">Пересчёт…</span>
{/if}
</div>
<DownloadButton
image={result}
format={outputOf(tool)}
baseName={tool.id}
{params}
onError={onDownloadError}
/>
<div class="actions-row">
<DownloadButton
image={result}
format={outputOf(tool)}
baseName={tool.id}
{params}
onError={onDownloadError}
/>
{#if onChainToggle}
<Button variant="secondary" fullWidth onclick={onChainToggle}>
{hasChain ? '✂ Оборвать цепочку' : '⛓ Следующий инструмент'}
</Button>
{/if}
</div>
{/if}
</div>
@@ -100,10 +102,6 @@
height: 100%;
}
h2 {
margin-bottom: var(--space-1);
}
.media {
flex: 1;
display: flex;
@@ -18,7 +18,6 @@
</script>
<div class="container">
<h2 class="heading-section">Исходник</h2>
{#if !source}
<DropZone {onFile} {onError} />
{:else}
@@ -40,10 +39,6 @@
height: 100%;
}
h2 {
margin-bottom: var(--space-1);
}
.media {
flex: 1;
display: flex;
+25 -11
View File
@@ -9,34 +9,48 @@
let { id, label, checked = $bindable(false), hint }: Props = $props();
</script>
<div class="checkbox">
<input id={id} type="checkbox" bind:checked />
<label for={id}>{label}</label>
<div class="checkbox-row">
<label class="checkbox" for={id}>
<input id={id} type="checkbox" bind:checked />
{label}
</label>
{#if hint}
<p class="hint text-caption text-muted">{hint}</p>
{/if}
</div>
{#if hint}
<p class="hint text-caption text-muted">{hint}</p>
{/if}
<style>
.checkbox-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
padding: calc(var(--space-1) + 2px) 0;
border-bottom: 1px dashed var(--border);
}
.checkbox {
display: flex;
align-items: center;
gap: var(--space-2);
margin-bottom: var(--space-3);
cursor: pointer;
font-size: var(--text-caption, var(--text-s));
color: var(--text-muted);
}
input[type='checkbox'] {
width: 1rem;
height: 1rem;
cursor: pointer;
accent-color: var(--accent);
}
label {
font-size: var(--text-m);
cursor: pointer;
label:hover {
color: var(--text);
}
.hint {
margin: calc(-1 * var(--space-3)) 0 var(--space-3);
margin: 0;
text-align: right;
}
</style>
+30 -5
View File
@@ -13,17 +13,42 @@
<div class="field">
<label class="text-caption text-muted" for={id}>{label}</label>
{@render children()}
{#if hint}
<p class="hint text-caption text-muted">{hint}</p>
{/if}
<div class="control-slot">
{@render children()}
{#if hint}
<p class="hint text-caption text-muted">{hint}</p>
{/if}
</div>
</div>
<style>
.field {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
padding: calc(var(--space-1) + 2px) 0;
border-bottom: 1px dashed var(--border);
}
label {
flex: none;
max-width: 45%;
text-align: left;
}
.control-slot {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
align-items: flex-end;
gap: var(--space-1);
margin-bottom: var(--space-3);
}
.hint {
margin: 0;
align-self: flex-end;
text-align: right;
}
</style>
+7 -5
View File
@@ -63,19 +63,21 @@
display: flex;
align-items: center;
gap: var(--space-1);
max-width: var(--control-max-width);
width: 100%;
max-width: 22rem;
}
input[type='range'] {
flex: 1;
min-width: 0;
min-width: 3rem;
accent-color: var(--accent);
margin: 0;
}
.step {
flex: none;
width: 1.7rem;
height: 1.7rem;
width: 1.5rem;
height: 1.5rem;
display: inline-flex;
align-items: center;
justify-content: center;
@@ -84,7 +86,7 @@
border-radius: var(--radius-s);
background: var(--surface);
color: var(--text);
font-size: 0.9rem;
font-size: 0.85rem;
line-height: 1;
cursor: pointer;
transition:
+67 -1
View File
@@ -1,7 +1,73 @@
import { describe, expect, it } from 'vitest';
import { colorMask, flattenOntoColor, invertAlpha, parseHex, removeColorToAlpha } from './alpha';
import {
colorMask,
extractAlphaMask,
flattenOntoColor,
hardenAlpha,
invertAlpha,
parseHex,
removeColorToAlpha,
roundCorners,
setAlphaChannel
} from './alpha';
import { makeImage } from './test-helpers';
describe('hardenAlpha', () => {
it('бинаризует альфу по порогу, RGB не трогает', () => {
const out = hardenAlpha(
makeImage(2, 1, [
[10, 20, 30, 100],
[40, 50, 60, 200]
]),
50
);
expect([...out.data]).toEqual([
10, 20, 30, 0,
40, 50, 60, 255
]);
});
});
describe('setAlphaChannel', () => {
it('задаёт константную альфу', () => {
const out = setAlphaChannel(makeImage(2, 1, [
[1, 2, 3, 255],
[4, 5, 6, 0]
]), 50);
expect(out.data[3]).toBe(128);
expect(out.data[7]).toBe(128);
expect(out.data[0]).toBe(1);
});
});
describe('extractAlphaMask', () => {
it('переводит альфу в чёрно-белую непрозрачную маску', () => {
const out = extractAlphaMask(makeImage(2, 1, [
[10, 20, 30, 255],
[40, 50, 60, 0]
]));
expect([...out.data]).toEqual([
255, 255, 255, 255,
0, 0, 0, 255
]);
});
});
describe('roundCorners', () => {
it('срезает углы, центр и середины сторон остаются', () => {
const img = makeImage(11, 11, new Array(121).fill([100, 100, 100, 255]));
const out = roundCorners(img, 40);
expect(out.data[(0 * 11 + 0) * 4 + 3]).toBe(0);
expect(out.data[(5 * 11 + 5) * 4 + 3]).toBe(255);
expect(out.data[(5 * 11 + 0) * 4 + 3]).toBe(255);
});
it('нулевой радиус ничего не меняет', () => {
const img = makeImage(2, 2, new Array(4).fill([1, 2, 3, 255]));
expect([...roundCorners(img, 0).data]).toEqual([...img.data]);
});
});
describe('invertAlpha', () => {
it('обращает альфу, RGB не трогает', () => {
const out = invertAlpha(makeImage(1, 1, [[10, 20, 30, 128]]));
+52
View File
@@ -22,6 +22,46 @@ export function removeColorToAlpha(
return out;
}
export function setAlphaChannel(img: PixelImage, percent: number): PixelImage {
const alpha = Math.round((clamp(percent, 0, 100) / 100) * 255);
const out: PixelImage = { width: img.width, height: img.height, data: img.data.slice() };
for (let i = 3; i < out.data.length; i += 4) {
out.data[i] = alpha;
}
return out;
}
export function extractAlphaMask(img: PixelImage): PixelImage {
const out = createPixelImage(img.width, img.height);
for (let i = 0; i < out.data.length; i += 4) {
const v = img.data[i + 3];
out.data[i] = v;
out.data[i + 1] = v;
out.data[i + 2] = v;
out.data[i + 3] = 255;
}
return out;
}
export function roundCorners(img: PixelImage, radiusPercent: number): PixelImage {
const radius = (clamp(radiusPercent, 0, 50) / 100) * (Math.min(img.width, img.height) / 2);
if (radius < 1) return { width: img.width, height: img.height, data: img.data.slice() };
const out: PixelImage = { width: img.width, height: img.height, data: img.data.slice() };
const r2 = radius * radius;
for (let y = 0; y < out.height; y++) {
for (let x = 0; x < out.width; x++) {
const cx = clamp(x, radius, out.width - 1 - radius);
const cy = clamp(y, radius, out.height - 1 - radius);
const dx = x - cx;
const dy = y - cy;
if (dx * dx + dy * dy > r2) {
out.data[(y * out.width + x) * 4 + 3] = 0;
}
}
}
return out;
}
export function invertAlpha(img: PixelImage): PixelImage {
const out = createPixelImage(img.width, img.height);
for (let i = 0; i < out.data.length; i += 4) {
@@ -33,6 +73,18 @@ export function invertAlpha(img: PixelImage): PixelImage {
return out;
}
export function hardenAlpha(img: PixelImage, thresholdPercent: number): PixelImage {
const threshold = (clamp(thresholdPercent, 0, 100) / 100) * 255;
const out = createPixelImage(img.width, img.height);
for (let i = 0; i < out.data.length; i += 4) {
out.data[i] = img.data[i];
out.data[i + 1] = img.data[i + 1];
out.data[i + 2] = img.data[i + 2];
out.data[i + 3] = img.data[i + 3] >= threshold ? 255 : 0;
}
return out;
}
export function colorMask(img: PixelImage, hex: string, tolerancePercent = 0): PixelImage {
const [targetR, targetG, targetB] = parseHex(hex);
const tolerance = (clamp(tolerancePercent, 0, 100) / 100) * MAX_COLOR_DISTANCE;
+32 -1
View File
@@ -1,5 +1,10 @@
import { describe, expect, it } from 'vitest';
import { imageInfo } from './analyze';
import {
hasTransparency,
imageInfo,
isGrayscale,
orientationOf
} from './analyze';
import { makeImage } from './test-helpers';
describe('imageInfo', () => {
@@ -43,3 +48,29 @@ describe('imageInfo', () => {
expect(info.height).toBe(3);
});
});
describe('isGrayscale', () => {
it('серые пиксели — монохром', () => {
expect(isGrayscale(makeImage(1, 1, [[10, 10, 10, 255]]))).toBe(true);
});
it('цветной пиксель ломает монохром', () => {
expect(isGrayscale(makeImage(1, 1, [[10, 11, 10, 255]]))).toBe(false);
});
});
describe('hasTransparency', () => {
it('альфа ниже 255 — прозрачность есть', () => {
expect(hasTransparency(makeImage(1, 1, [[0, 0, 0, 254]]))).toBe(true);
});
it('все пиксели непрозрачны', () => {
expect(hasTransparency(makeImage(1, 1, [[0, 0, 0, 255]]))).toBe(false);
});
});
describe('orientationOf', () => {
it('определяет ориентацию', () => {
expect(orientationOf(makeImage(2, 3, new Array(6).fill([1, 1, 1, 1])))).toBe('portrait');
expect(orientationOf(makeImage(3, 2, new Array(6).fill([1, 1, 1, 1])))).toBe('landscape');
expect(orientationOf(makeImage(2, 2, new Array(4).fill([1, 1, 1, 1])))).toBe('square');
});
});
+24
View File
@@ -24,3 +24,27 @@ export function imageInfo(img: PixelImage): ImageInfo {
}
return { width: img.width, height: img.height, hasAlpha, colorCount: seen.size };
}
export function isGrayscale(img: PixelImage): boolean {
for (let i = 0; i < img.data.length; i += 4) {
if (img.data[i] !== img.data[i + 1] || img.data[i + 1] !== img.data[i + 2]) {
return false;
}
}
return true;
}
export function hasTransparency(img: PixelImage): boolean {
for (let i = 3; i < img.data.length; i += 4) {
if (img.data[i] < 255) return true;
}
return false;
}
export type Orientation = 'portrait' | 'landscape' | 'square';
export function orientationOf(img: PixelImage): Orientation {
if (img.height > img.width) return 'portrait';
if (img.width > img.height) return 'landscape';
return 'square';
}
+108
View File
@@ -0,0 +1,108 @@
import { describe, expect, it } from 'vitest';
import {
backgroundMaskPreview,
backgroundRemovalMask,
removeBackground
} from './background';
import { makeImage } from './test-helpers';
const GREEN = [0, 255, 0, 255];
const RED = [255, 0, 0, 255];
describe('backgroundRemovalMask', () => {
it('глобальный режим удаляет все совпадающие пиксели', () => {
const mask = backgroundRemovalMask(
makeImage(2, 1, [GREEN, RED]),
{ color: '#00ff00', tolerancePercent: 0, outerOnly: false, smoothPasses: 0 }
);
expect([...mask]).toEqual([1, 0]);
});
describe('режим внешних областей', () => {
const ringRedCenterGreen = [
RED, RED, RED,
RED, GREEN, RED,
RED, RED, RED
];
it('заливка от краёв не достаёт до изолированного совпадающего острова', () => {
const mask = backgroundRemovalMask(
makeImage(3, 3, ringRedCenterGreen),
{ color: '#00ff00', tolerancePercent: 0, outerOnly: true, smoothPasses: 0 }
);
expect(mask[4]).toBe(0);
});
it('глобальный режим удаляет и изолированный остров', () => {
const mask = backgroundRemovalMask(
makeImage(3, 3, ringRedCenterGreen),
{ color: '#00ff00', tolerancePercent: 0, outerOnly: false, smoothPasses: 0 }
);
expect(mask[4]).toBe(1);
expect(mask[0]).toBe(0);
});
});
it('допуск расширяет захват по цветовому расстоянию', () => {
const img = makeImage(2, 1, [
[10, 10, 10, 255],
[128, 128, 128, 255]
]);
const tight = backgroundRemovalMask(img, {
color: '#000000', tolerancePercent: 40, outerOnly: false, smoothPasses: 0
});
const wide = backgroundRemovalMask(img, {
color: '#000000', tolerancePercent: 60, outerOnly: false, smoothPasses: 0
});
expect(tight[1]).toBe(0);
expect(wide[1]).toBe(1);
});
});
describe('smoothMask-поведение через backgroundRemovalMask', () => {
const ringRedCenterGreen = [
RED, RED, RED,
RED, GREEN, RED,
RED, RED, RED
];
const opts = { color: '#00ff00', tolerancePercent: 0, outerOnly: false };
const alphaAtCenter = (img: { data: Uint8ClampedArray }) => img.data[19];
it('без сглаживания центр удалён', () => {
const img = removeBackground(makeImage(3, 3, ringRedCenterGreen), { ...opts, smoothPasses: 0 });
expect(alphaAtCenter(img)).toBe(0);
});
it('два прохода мажоритарного фильтра возвращают изолированный пиксель', () => {
const img = removeBackground(makeImage(3, 3, ringRedCenterGreen), { ...opts, smoothPasses: 2 });
expect(alphaAtCenter(img)).toBe(255);
});
});
describe('removeBackground', () => {
it('обнуляет альфу удалённых, сохраняет RGB остальных', () => {
const out = removeBackground(
makeImage(2, 1, [GREEN, [5, 6, 7, 200]]),
{ color: '#00ff00', tolerancePercent: 0, outerOnly: false, smoothPasses: 0 }
);
expect(out.data[3]).toBe(0);
expect([...out.data.slice(4, 8)]).toEqual([5, 6, 7, 200]);
});
});
describe('backgroundMaskPreview', () => {
it('белое там, где удаление, чёрное — где остаёмся, всё непрозрачно', () => {
const preview = backgroundMaskPreview(
makeImage(2, 1, [
GREEN,
[9, 9, 9, 60]
]),
{ color: '#00ff00', tolerancePercent: 0, outerOnly: false, smoothPasses: 0 }
);
expect([...preview.data]).toEqual([
255, 255, 255, 255,
0, 0, 0, 255
]);
});
});
+131
View File
@@ -0,0 +1,131 @@
import { parseHex } from './alpha';
import { createPixelImage, type PixelImage } from './types';
export type BackgroundOptions = {
color: string;
tolerancePercent: number;
outerOnly: boolean;
smoothPasses: number;
};
function buildRawMask(
img: PixelImage,
targetR: number,
targetG: number,
targetB: number,
tolerancePercent: number
): Uint8Array {
const tolerance = (clamp(tolerancePercent, 0, 100) / 100) * Math.sqrt(3 * 255 * 255);
const thresholdSq = tolerance * tolerance;
const mask = new Uint8Array(img.width * img.height);
for (let i = 0; i < mask.length; i++) {
const dr = img.data[i * 4] - targetR;
const dg = img.data[i * 4 + 1] - targetG;
const db = img.data[i * 4 + 2] - targetB;
mask[i] = dr * dr + dg * dg + db * db <= thresholdSq ? 1 : 0;
}
return mask;
}
function floodFromBorders(mask: Uint8Array, w: number, h: number): void {
const queue: number[] = [];
const push = (index: number) => {
if (mask[index] === 1) {
mask[index] = 2;
queue.push(index);
}
};
for (let x = 0; x < w; x++) {
push(x);
push((h - 1) * w + x);
}
for (let y = 0; y < h; y++) {
push(y * w);
push(y * w + w - 1);
}
let head = 0;
while (head < queue.length) {
const index = queue[head++];
const x = index % w;
if (x > 0) push(index - 1);
if (x < w - 1) push(index + 1);
if (index >= w) push(index - w);
if (index < (h - 1) * w) push(index + w);
}
for (let i = 0; i < mask.length; i++) {
mask[i] = mask[i] === 2 ? 1 : 0;
}
}
export function smoothMask(
mask: Uint8Array,
w: number,
h: number,
passes: number
): Uint8Array {
let current = mask;
const count = clamp(Math.trunc(passes), 0, 8);
for (let pass = 0; pass < count; pass++) {
const next = new Uint8Array(current.length);
for (let y = 0; y < h; y++) {
for (let x = 0; x < w; x++) {
let removed = 0;
let total = 0;
for (let dy = -1; dy <= 1; dy++) {
const sy = clamp(y + dy, 0, h - 1);
for (let dx = -1; dx <= 1; dx++) {
const sx = clamp(x + dx, 0, w - 1);
removed += current[sy * w + sx];
total++;
}
}
next[y * w + x] = removed * 2 >= total ? 1 : 0;
}
}
current = next;
}
return current;
}
export function backgroundRemovalMask(
img: PixelImage,
options: BackgroundOptions
): Uint8Array {
const [tr, tg, tb] = parseHex(options.color);
const mask = buildRawMask(img, tr, tg, tb, options.tolerancePercent);
if (options.outerOnly) {
floodFromBorders(mask, img.width, img.height);
}
return smoothMask(mask, img.width, img.height, options.smoothPasses);
}
export function removeBackground(img: PixelImage, options: BackgroundOptions): PixelImage {
const mask = backgroundRemovalMask(img, options);
const out = createPixelImage(img.width, img.height);
for (let i = 0; i < mask.length; i++) {
const di = i * 4;
out.data[di] = img.data[di];
out.data[di + 1] = img.data[di + 1];
out.data[di + 2] = img.data[di + 2];
out.data[di + 3] = mask[i] === 1 ? 0 : img.data[di + 3];
}
return out;
}
export function backgroundMaskPreview(img: PixelImage, options: BackgroundOptions): PixelImage {
const mask = backgroundRemovalMask(img, options);
const out = createPixelImage(img.width, img.height);
for (let i = 0; i < mask.length; i++) {
const v = mask[i] === 1 ? 255 : 0;
const di = i * 4;
out.data[di] = v;
out.data[di + 1] = v;
out.data[di + 2] = v;
out.data[di + 3] = 255;
}
return out;
}
function clamp(value: number, min: number, max: number): number {
return Math.min(max, Math.max(min, value));
}
+154
View File
@@ -0,0 +1,154 @@
import { describe, expect, it } from 'vitest';
import { convolve, gaussianBlur, sharpen } from './convolution';
import { makeImage } from './test-helpers';
const SHARPEN_KERNEL = [0, -1, 0, -1, 5, -1, 0, -1, 0];
describe('convolve', () => {
it('крестовое ядро резкости на полоске из трёх пикселей', () => {
const out = convolve(
makeImage(3, 1, [
[0, 0, 0, 255],
[100, 100, 100, 255],
[0, 0, 0, 255]
]),
SHARPEN_KERNEL,
3
);
expect([...out.data.slice(4, 8)]).toEqual([255, 255, 255, 255]);
expect([...out.data.slice(0, 4)]).toEqual([0, 0, 0, 255]);
expect([...out.data.slice(8, 12)]).toEqual([0, 0, 0, 255]);
});
it.each([
[2, 3],
[3.5, 3],
[0, 3]
])('бросает ошибку на некорректном размере ядра %i', (size) => {
expect(() => convolve(makeImage(1, 1, [[0, 0, 0, 255]]), [1], size as number)).toThrow();
});
});
describe('sharpen', () => {
it('сила 0 возвращает копию', () => {
const img = makeImage(2, 2, [
[10, 20, 30, 255],
[40, 50, 60, 128],
[70, 80, 90, 255],
[100, 110, 120, 200]
]);
expect([...sharpen(img, 0).data]).toEqual([...img.data]);
});
it('сила 100 применяет чистое ядро резкости', () => {
const out = sharpen(
makeImage(3, 1, [
[0, 0, 0, 255],
[100, 100, 100, 255],
[0, 0, 0, 255]
]),
100
);
expect(out.data[4]).toBe(255);
expect(out.data[0]).toBe(0);
});
});
describe('gaussianBlur', () => {
it('постоянное изображение не меняется ни в RGB, ни в альфе', () => {
const img = makeImage(3, 3, new Array(9).fill([40, 80, 120, 128]));
const out = gaussianBlur(img, 16);
for (let i = 0; i < out.data.length; i++) {
expect(out.data[i]).toBe(img.data[i]);
}
});
it('далёкие углы остаются прозрачными, цвет центра не искажается', () => {
const size = 61;
const pixels: number[][] = [];
for (let y = 0; y < size; y++) {
for (let x = 0; x < size; x++) {
pixels.push(
x >= 26 && x <= 34 && y >= 26 && y <= 34 ? [200, 50, 25, 255] : [0, 0, 0, 0]
);
}
}
const out = gaussianBlur(makeImage(size, size, pixels), 8);
const corner = 0;
expect(out.data[corner]).toBe(0);
expect(out.data[corner + 1]).toBe(0);
expect(out.data[corner + 2]).toBe(0);
expect(out.data[corner + 3]).toBe(0);
const center = (30 * size + 30) * 4;
expect(out.data[center]).toBe(200);
expect(out.data[center + 1]).toBe(50);
expect(out.data[center + 2]).toBe(25);
});
it('симметричный вход даёт симметричный результат', () => {
const leftByRow = [
[
[255, 0, 0, 255],
[10, 20, 30, 255],
[64, 64, 64, 64],
[5, 5, 5, 200]
],
[
[10, 20, 30, 255],
[200, 100, 50, 255],
[1, 2, 3, 4],
[90, 90, 90, 250]
],
[
[64, 64, 64, 64],
[1, 2, 3, 4],
[128, 128, 128, 128],
[40, 40, 40, 240]
],
[
[200, 100, 50, 255],
[90, 90, 90, 250],
[40, 40, 40, 240],
[7, 7, 7, 255]
],
[
[10, 20, 30, 255],
[1, 2, 3, 4],
[64, 64, 64, 64],
[90, 90, 90, 250]
],
[
[64, 64, 64, 64],
[200, 100, 50, 255],
[5, 5, 5, 200],
[1, 2, 3, 4]
],
[
[5, 5, 5, 200],
[40, 40, 40, 240],
[90, 90, 90, 250],
[128, 128, 128, 128]
]
];
const pixels: number[][] = [];
for (let y = 0; y < 7; y++) {
for (let x = 0; x < 7; x++) {
pixels.push(leftByRow[y][Math.min(x, 6 - x)]);
}
}
const img = makeImage(7, 7, pixels);
const blurred = gaussianBlur(img, 3);
for (let y = 0; y < 7; y++) {
for (let x = 0; x < 3; x++) {
const li = (y * 7 + x) * 4;
const ri = (y * 7 + (6 - x)) * 4;
expect([...blurred.data.slice(li, li + 4)]).toEqual([
blurred.data[ri],
blurred.data[ri + 1],
blurred.data[ri + 2],
blurred.data[ri + 3]
]);
}
}
});
});
+177
View File
@@ -0,0 +1,177 @@
import { clonePixelImage, createPixelImage, type PixelImage } from './types';
type Plane = Float64Array;
export function convolve(
img: PixelImage,
kernel: readonly number[],
size: number
): PixelImage {
if (!Number.isInteger(size) || size < 1 || size % 2 === 0) {
throw new Error('Размер ядра должен быть нечётным положительным числом');
}
if (kernel.length !== size * size) {
throw new Error('Длина ядра не совпадает с его размером');
}
const half = Math.floor(size / 2);
const out = createPixelImage(img.width, img.height);
for (let y = 0; y < out.height; y++) {
for (let x = 0; x < out.width; x++) {
for (let ch = 0; ch < 3; ch++) {
let acc = 0;
for (let ky = 0; ky < size; ky++) {
const sy = clampInt(y + ky - half, 0, img.height - 1);
for (let kx = 0; kx < size; kx++) {
const sx = clampInt(x + kx - half, 0, img.width - 1);
acc += img.data[(sy * img.width + sx) * 4 + ch] * kernel[ky * size + kx];
}
}
out.data[(y * out.width + x) * 4 + ch] = acc;
}
out.data[(y * out.width + x) * 4 + 3] = img.data[(y * img.width + x) * 4 + 3];
}
}
return out;
}
const SHARPEN_KERNEL = [0, -1, 0, -1, 5, -1, 0, -1, 0];
export function sharpen(img: PixelImage, strengthPercent: number): PixelImage {
const strength = clamp(strengthPercent, 0, 100) / 100;
if (strength === 0) return clonePixelImage(img);
const sharp = convolve(img, SHARPEN_KERNEL, 3);
const out = createPixelImage(img.width, img.height);
for (let i = 0; i < out.data.length; i += 4) {
for (let ch = 0; ch < 3; ch++) {
out.data[i + ch] =
img.data[i + ch] * (1 - strength) + sharp.data[i + ch] * strength;
}
out.data[i + 3] = img.data[i + 3];
}
return out;
}
export function gaussianBlur(img: PixelImage, radiusPx: number): PixelImage {
const radius = clamp(radiusPx, 0, 512);
if (radius < 1) return clonePixelImage(img);
const sigma = Math.max(0.25, radius / 2);
const boxes = boxesForGauss(sigma, 3);
const w = img.width;
const h = img.height;
const pixels = w * h;
const red = new Float64Array(pixels);
const green = new Float64Array(pixels);
const blue = new Float64Array(pixels);
const alpha = new Float64Array(pixels);
for (let i = 0; i < pixels; i++) {
const a = img.data[i * 4 + 3] / 255;
red[i] = (img.data[i * 4] / 255) * a;
green[i] = (img.data[i * 4 + 1] / 255) * a;
blue[i] = (img.data[i * 4 + 2] / 255) * a;
alpha[i] = a;
}
const tmp = new Float64Array(pixels);
for (const box of boxes) {
const r = Math.max(0, (box - 1) / 2);
if (r < 1) continue;
blurPlanePass(red, tmp, w, h, r);
blurPlanePass(green, tmp, w, h, r);
blurPlanePass(blue, tmp, w, h, r);
blurPlanePass(alpha, tmp, w, h, r);
}
const out = createPixelImage(w, h);
for (let i = 0; i < pixels; i++) {
const a = alpha[i];
const di = i * 4;
if (a < 1 / 255) {
out.data[di] = 0;
out.data[di + 1] = 0;
out.data[di + 2] = 0;
out.data[di + 3] = 0;
continue;
}
out.data[di] = (red[i] / a) * 255;
out.data[di + 1] = (green[i] / a) * 255;
out.data[di + 2] = (blue[i] / a) * 255;
out.data[di + 3] = a * 255;
}
return out;
}
function blurPlanePass(plane: Plane, tmp: Plane, w: number, h: number, r: number): void {
blurPlaneHorizontal(plane, tmp, w, h, r);
blurPlaneVertical(tmp, plane, w, h, r);
}
function blurPlaneHorizontal(
src: Plane,
dst: Plane,
w: number,
h: number,
r: number
): void {
const div = 2 * r + 1;
const inv = 1 / div;
for (let y = 0; y < h; y++) {
const row = y * w;
let sum = src[row] * (r + 1);
for (let k = 1; k <= r; k++) {
sum += src[row + clampInt(k, 0, w - 1)];
}
for (let x = 0; x < w; x++) {
dst[row + x] = sum * inv;
const addIndex = clampInt(x + r + 1, 0, w - 1);
const removeIndex = clampInt(x - r, 0, w - 1);
sum += src[row + addIndex] - src[row + removeIndex];
}
}
}
function blurPlaneVertical(
src: Plane,
dst: Plane,
w: number,
h: number,
r: number
): void {
const div = 2 * r + 1;
const inv = 1 / div;
for (let x = 0; x < w; x++) {
let sum = src[x] * (r + 1);
for (let k = 1; k <= r; k++) {
sum += src[clampInt(k, 0, h - 1) * w + x];
}
for (let y = 0; y < h; y++) {
dst[y * w + x] = sum * inv;
const addIndex = clampInt(y + r + 1, 0, h - 1);
const removeIndex = clampInt(y - r, 0, h - 1);
sum += src[addIndex * w + x] - src[removeIndex * w + x];
}
}
}
function boxesForGauss(sigma: number, boxes: number): number[] {
const wIdeal = Math.sqrt((12 * sigma * sigma) / boxes + 1);
let wl = Math.floor(wIdeal);
if (wl % 2 === 0) wl--;
const wu = wl + 2;
const mIdeal = (12 * sigma * sigma - boxes * wl * wl - boxes * wl - boxes) / (4 * wl + 4);
const m = Math.round(mIdeal);
const sizes: number[] = [];
for (let i = 0; i < boxes; i++) {
sizes.push(i < m ? wl : wu);
}
return sizes;
}
function clamp(v: number, min: number, max: number): number {
return Math.min(max, Math.max(min, v));
}
function clampInt(value: number, min: number, max: number): number {
return Math.min(max, Math.max(min, Math.trunc(value)));
}
+66
View File
@@ -0,0 +1,66 @@
import { describe, expect, it } from 'vitest';
import { gradientImage, noiseImage, solidImage } from './generate';
describe('solidImage', () => {
it('заливает весь холст заданным цветом', () => {
const out = solidImage(2, 2, [10, 20, 30, 255]);
expect(out.width).toBe(2);
expect([...out.data]).toEqual(new Array(4).fill([10, 20, 30, 255]).flat());
});
it.each([
[0, 10],
[10, 0],
[2.5, 10],
[-1, 5]
])('бросает ошибку на размерах %i x %i', (w, h) => {
expect(() => solidImage(w, h, [0, 0, 0, 255])).toThrow();
});
});
describe('noiseImage', () => {
it('детерминирован: одно зерно — одни байты', () => {
expect([...noiseImage(4, 4, 42).data]).toEqual([...noiseImage(4, 4, 42).data]);
});
it('разные зерна дают разные данные', () => {
const a = [...noiseImage(8, 8, 1).data];
const b = [...noiseImage(8, 8, 2).data];
expect(a).not.toEqual(b);
});
it('альфа всегда непрозрачная', () => {
const data = noiseImage(3, 3, 7).data;
for (let i = 3; i < data.length; i += 4) {
expect(data[i]).toBe(255);
}
});
});
describe('gradientImage', () => {
it('горизонтальный градиент идёт от цвета A к цвету B', () => {
const out = gradientImage(
3,
1,
[0, 0, 0, 255],
[255, 255, 255, 255],
'horizontal'
);
const px = (x: number) => [...out.data.slice(x * 4, x * 4 + 4)];
expect(px(0)).toEqual([0, 0, 0, 255]);
expect(px(1)).toEqual([128, 128, 128, 255]);
expect(px(2)).toEqual([255, 255, 255, 255]);
});
it('вертикальный градиент меняется по строкам', () => {
const out = gradientImage(
1,
2,
[0, 0, 0, 255],
[100, 100, 100, 255],
'vertical'
);
expect(out.data[0]).toBe(0);
expect(out.data[4]).toBe(100);
});
});
+74
View File
@@ -0,0 +1,74 @@
import type { PixelImage } from './types';
export function solidImage(
width: number,
height: number,
rgba: [number, number, number, number]
): PixelImage {
if (!Number.isInteger(width) || !Number.isInteger(height) || width < 1 || height < 1) {
throw new Error('Размеры должны быть целыми числами >= 1');
}
const data = new Uint8ClampedArray(width * height * 4);
for (let i = 0; i < data.length; i += 4) {
data[i] = rgba[0];
data[i + 1] = rgba[1];
data[i + 2] = rgba[2];
data[i + 3] = rgba[3];
}
return { width, height, data };
}
export function noiseImage(width: number, height: number, seed: number): PixelImage {
if (!Number.isInteger(width) || !Number.isInteger(height) || width < 1 || height < 1) {
throw new Error('Размеры должны быть целыми числами >= 1');
}
const random = mulberry32(seed);
const data = new Uint8ClampedArray(width * height * 4);
for (let i = 0; i < data.length; i += 4) {
data[i] = Math.floor(random() * 256);
data[i + 1] = Math.floor(random() * 256);
data[i + 2] = Math.floor(random() * 256);
data[i + 3] = 255;
}
return { width, height, data };
}
export function gradientImage(
width: number,
height: number,
fromRgba: [number, number, number, number],
toRgba: [number, number, number, number],
direction: 'horizontal' | 'vertical'
): PixelImage {
if (!Number.isInteger(width) || !Number.isInteger(height) || width < 1 || height < 1) {
throw new Error('Размеры должны быть целыми числами >= 1');
}
const out: PixelImage = {
width,
height,
data: new Uint8ClampedArray(width * height * 4)
};
const steps = (direction === 'horizontal' ? width : height) - 1;
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
const t = steps === 0 ? 0 : (direction === 'horizontal' ? x : y) / steps;
const i = (y * width + x) * 4;
out.data[i] = fromRgba[0] + (toRgba[0] - fromRgba[0]) * t;
out.data[i + 1] = fromRgba[1] + (toRgba[1] - fromRgba[1]) * t;
out.data[i + 2] = fromRgba[2] + (toRgba[2] - fromRgba[2]) * t;
out.data[i + 3] = fromRgba[3] + (toRgba[3] - fromRgba[3]) * t;
}
}
return out;
}
function mulberry32(seed: number): () => number {
let a = seed >>> 0;
return () => {
a |= 0;
a = (a + 0x6d2b79f5) | 0;
let t = Math.imul(a ^ (a >>> 15), 1 | a);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
+59 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest';
import { crop, flip, resize, rotate90 } from './geometry';
import { centerByAlpha, crop, expandCanvas, flip, resize, rotate90, tile } from './geometry';
import { makeImage } from './test-helpers';
const square = () =>
@@ -126,6 +126,64 @@ describe('crop', () => {
});
});
describe('expandCanvas', () => {
const pixel = () => makeImage(1, 1, [[10, 20, 30, 255]]);
it('прозрачное расширение кладёт пиксель со смещением', () => {
const out = expandCanvas(pixel(), 1, 2, 3, 4);
expect(out.width).toBe(5);
expect(out.height).toBe(7);
expect([...out.data.slice(0, 4)]).toEqual([0, 0, 0, 0]);
expect([...out.data.slice((2 * 5 + 1) * 4, (2 * 5 + 1) * 4 + 4)]).toEqual([10, 20, 30, 255]);
});
it('цветной фон заливает всё вокруг', () => {
const out = expandCanvas(pixel(), 1, 0, 0, 0, '#ffffff');
expect(out.data[0]).toBe(255);
expect(out.data[3]).toBe(255);
expect(out.data[(0 * 2 + 1) * 4 + 3]).toBe(255);
});
});
describe('tile', () => {
it('повторяет изображение по сетке', () => {
const out = tile(makeImage(1, 1, [[9, 9, 9, 255]]), 3, 2);
expect(out.width).toBe(3);
expect(out.height).toBe(2);
expect([...out.data].filter((_, i) => i % 4 === 0)).toEqual([9, 9, 9, 9, 9, 9]);
});
});
describe('centerByAlpha', () => {
it('вырезает непрозрачный блок и центрирует на прежнем холсте', () => {
const img = makeImage(3, 3, [
[0, 0, 0, 0],
[0, 0, 0, 0],
[0, 0, 0, 0],
[0, 0, 0, 0],
[5, 5, 5, 255],
[0, 0, 0, 0],
[0, 0, 0, 0],
[0, 0, 0, 0],
[0, 0, 0, 0]
]);
const out = centerByAlpha(img);
expect(out.width).toBe(3);
expect(out.height).toBe(3);
const alphaAt = (x: number, y: number) => out.data[(y * 3 + x) * 4 + 3];
expect(alphaAt(0, 0)).toBe(0);
expect(alphaAt(1, 1)).toBe(255);
});
it('полностью прозрачное изображение возвращается без изменений', () => {
const img = makeImage(2, 1, [
[0, 0, 0, 0],
[0, 0, 0, 0]
]);
expect([...centerByAlpha(img).data]).toEqual([...img.data]);
});
});
describe('resize', () => {
const twoByTwo = () =>
makeImage(2, 2, [
+80
View File
@@ -1,5 +1,85 @@
import { parseHex } from './alpha';
import { clonePixelImage, createPixelImage, type PixelImage } from './types';
export function expandCanvas(
img: PixelImage,
left: number,
top: number,
right: number,
bottom: number,
backgroundHex?: string
): PixelImage {
const l = Math.max(0, Math.trunc(left));
const t = Math.max(0, Math.trunc(top));
const r = Math.max(0, Math.trunc(right));
const b = Math.max(0, Math.trunc(bottom));
const out = createPixelImage(img.width + l + r, img.height + t + b);
if (backgroundHex !== undefined) {
const [cr, cg, cb] = parseHex(backgroundHex);
for (let i = 0; i < out.data.length; i += 4) {
out.data[i] = cr;
out.data[i + 1] = cg;
out.data[i + 2] = cb;
out.data[i + 3] = 255;
}
}
for (let y = 0; y < img.height; y++) {
const srcStart = y * img.width * 4;
out.data.set(
img.data.subarray(srcStart, srcStart + img.width * 4),
((y + t) * out.width + l) * 4
);
}
return out;
}
export function tile(img: PixelImage, columns: number, rows: number): PixelImage {
const cols = Math.max(1, Math.trunc(columns));
const rowsCount = Math.max(1, Math.trunc(rows));
const out = createPixelImage(img.width * cols, img.height * rowsCount);
for (let ty = 0; ty < rowsCount; ty++) {
for (let tx = 0; tx < cols; tx++) {
for (let y = 0; y < img.height; y++) {
const srcStart = y * img.width * 4;
out.data.set(
img.data.subarray(srcStart, srcStart + img.width * 4),
((ty * img.height + y) * out.width + tx * img.width) * 4
);
}
}
}
return out;
}
export function centerByAlpha(img: PixelImage): PixelImage {
let minX = img.width;
let minY = img.height;
let maxX = -1;
let maxY = -1;
for (let y = 0; y < img.height; y++) {
for (let x = 0; x < img.width; x++) {
if (img.data[(y * img.width + x) * 4 + 3] > 0) {
if (x < minX) minX = x;
if (y < minY) minY = y;
if (x > maxX) maxX = x;
if (y > maxY) maxY = y;
}
}
}
if (maxX < 0) return clonePixelImage(img);
const content = crop(img, minX, minY, maxX - minX + 1, maxY - minY + 1);
const out = createPixelImage(img.width, img.height);
const dx = Math.floor((img.width - content.width) / 2);
const dy = Math.floor((img.height - content.height) / 2);
for (let y = 0; y < content.height; y++) {
out.data.set(
content.data.subarray(y * content.width * 4, (y + 1) * content.width * 4),
((y + dy) * out.width + dx) * 4
);
}
return out;
}
export type FlipAxis = 'horizontal' | 'vertical';
export function flip(img: PixelImage, axis: FlipAxis): PixelImage {
+168
View File
@@ -0,0 +1,168 @@
import { describe, expect, it } from 'vitest';
import {
buildAlphaMask,
closingImage,
contourImage,
dilateImage,
dilateMask,
erodeImage,
erodeMask,
openingImage,
strokeImage
} from './morphology';
import { makeImage } from './test-helpers';
function maskFrom(rows: string[]): Uint8Array {
const flat = rows.join('');
const mask = new Uint8Array(flat.length);
for (let i = 0; i < flat.length; i++) {
mask[i] = flat[i] === '#' ? 1 : 0;
}
return mask;
}
function toRows(mask: Uint8Array, w: number): string[] {
const rows: string[] = [];
for (let y = 0; y < mask.length / w; y++) {
let row = '';
for (let x = 0; x < w; x++) {
row += mask[y * w + x] === 1 ? '#' : '.';
}
rows.push(row);
}
return rows;
}
describe('dilateMask', () => {
it('одиночный пиксель r=1 превращается в плюс', () => {
const out = dilateMask(
maskFrom(['.....', '.....', '..#..', '.....', '.....']),
5,
5,
1
);
expect(toRows(out, 5)).toEqual(['.....', '..#..', '.###.', '..#..', '.....']);
});
it('r=2 даёт ромб радиуса 2', () => {
const out = dilateMask(
maskFrom(['.....', '.....', '..#..', '.....', '.....']),
5,
5,
2
);
expect(toRows(out, 5)).toEqual(['..#..', '.###.', '#####', '.###.', '..#..']);
});
});
describe('erodeMask', () => {
it('сплошной объект во весь кадр не сжимается от границ', () => {
const solid = maskFrom(['#####', '#####', '#####', '#####', '#####']);
expect([...erodeMask(solid, 5, 5, 1)]).toEqual([...solid]);
});
it('изолированный пиксель исчезает', () => {
const out = erodeMask(
maskFrom(['.....', '.....', '..#..', '.....', '.....']),
5,
5,
1
);
expect([...out].every((v) => v === 0)).toBe(true);
});
});
describe('opening/closing образы', () => {
it('opening убирает отстоящий мусорный пиксель и сохраняет блок', () => {
const B = [10, 10, 10, 255];
const T = [0, 0, 0, 0];
const S = [200, 200, 200, 255];
const out = openingImage(
makeImage(
6,
3,
[B, B, B, T, S, T, B, B, B, T, T, T, B, B, B, T, T, T]
),
1
);
const at = (x: number, y: number) => out.data[(y * 6 + x) * 4 + 3];
expect(at(0, 1)).toBe(255);
expect(at(2, 1)).toBe(255);
expect(at(4, 1)).toBe(0);
});
it('closing заполняет одиночную прозрачную дыру чёрным', () => {
const pixels: number[][] = [];
for (let y = 0; y < 3; y++) {
for (let x = 0; x < 3; x++) {
pixels.push(x === 1 && y === 1 ? [0, 0, 0, 0] : [255, 255, 255, 255]);
}
}
const out = closingImage(makeImage(3, 3, pixels), 1);
const di = (1 * 3 + 1) * 4;
expect(out.data[di + 3]).toBe(255);
});
});
describe('image-обёртки', () => {
it('dilateImage сохраняет RGB содержимого, новые пиксели непрозрачны', () => {
const T = [0, 0, 0, 0];
const O = [200, 50, 25, 255];
const img = makeImage(5, 2, [T, O, T, T, T, T, T, T, T, T]);
const out = dilateImage(img, 1);
const at = (x: number, y: number) => {
const di = (y * 5 + x) * 4;
return [out.data[di], out.data[di + 1], out.data[di + 2], out.data[di + 3]];
};
expect(at(1, 0)).toEqual([200, 50, 25, 255]);
expect(at(0, 0)).toEqual([0, 0, 0, 255]);
expect(at(4, 0)[3]).toBe(0);
});
it('erodeImage не стирает объект у края кадра', () => {
const img = makeImage(2, 2, [
[10, 20, 30, 255],
[10, 20, 30, 255],
[10, 20, 30, 255],
[10, 20, 30, 255]
]);
const out = erodeImage(img, 1);
for (let i = 0; i < out.data.length; i += 4) {
expect(out.data[i + 3]).toBe(255);
}
});
});
describe('strokeImage', () => {
it('кольцо цвета обводки вокруг квадрата', () => {
const pixels: number[][] = [];
for (let y = 0; y < 7; y++) {
for (let x = 0; x < 7; x++) {
pixels.push(x >= 2 && x <= 4 && y >= 2 && y <= 4 ? [255, 0, 0, 255] : [0, 0, 0, 0]);
}
}
const out = strokeImage(makeImage(7, 7, pixels), 1, '#0000ff');
const at = (x: number, y: number) =>
[...out.data.slice((y * 7 + x) * 4, (y * 7 + x) * 4 + 4)];
expect(at(2, 3)).toEqual([255, 0, 0, 255]);
expect(at(1, 3)).toEqual([0, 0, 255, 255]);
expect(at(0, 0)).toEqual([0, 0, 0, 0]);
});
});
describe('contourImage', () => {
it('линия по краю квадрата, центр прозрачен', () => {
const pixels: number[][] = [];
for (let y = 0; y < 7; y++) {
for (let x = 0; x < 7; x++) {
pixels.push(x >= 2 && x <= 4 && y >= 2 && y <= 4 ? [255, 0, 0, 255] : [0, 0, 0, 0]);
}
}
const out = contourImage(makeImage(7, 7, pixels), 1, '#0000ff');
const at = (x: number, y: number) =>
[...out.data.slice((y * 7 + x) * 4, (y * 7 + x) * 4 + 4)];
expect(at(2, 2)).toEqual([0, 0, 255, 255]);
expect(at(3, 3)).toEqual([0, 0, 0, 0]);
expect(at(1, 3)).toEqual([0, 0, 0, 0]);
});
});
+192
View File
@@ -0,0 +1,192 @@
import { parseHex } from './alpha';
import { clonePixelImage, createPixelImage, type PixelImage } from './types';
type Mask = Uint8Array;
type Offset = { dx: number; dy: number };
function discOffsets(radius: number): Offset[] {
const offsets: Offset[] = [];
for (let dy = -radius; dy <= radius; dy++) {
for (let dx = -radius; dx <= radius; dx++) {
if (dx * dx + dy * dy <= radius * radius) {
offsets.push({ dx, dy });
}
}
}
return offsets;
}
export function buildAlphaMask(img: PixelImage): Mask {
const mask = new Uint8Array(img.width * img.height);
for (let i = 0; i < mask.length; i++) {
mask[i] = img.data[i * 4 + 3] > 0 ? 1 : 0;
}
return mask;
}
export function dilateMask(mask: Mask, width: number, height: number, radius: number): Mask {
const r = Math.trunc(radius);
if (r < 1) return mask.slice();
const out = new Uint8Array(mask.length);
const offsets = discOffsets(r);
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
let hit = 0;
for (const { dx, dy } of offsets) {
const sx = x + dx;
const sy = y + dy;
if (sx < 0 || sy < 0 || sx >= width || sy >= height) continue;
if (mask[sy * width + sx] === 1) {
hit = 1;
break;
}
}
out[y * width + x] = hit;
}
}
return out;
}
export function erodeMask(mask: Mask, width: number, height: number, radius: number): Mask {
const r = Math.trunc(radius);
if (r < 1) return mask.slice();
const out = new Uint8Array(mask.length);
const offsets = discOffsets(r);
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
let solid = 1;
for (const { dx, dy } of offsets) {
const sx = x + dx;
const sy = y + dy;
if (sx < 0 || sy < 0 || sx >= width || sy >= height) continue;
if (mask[sy * width + sx] !== 1) {
solid = 0;
break;
}
}
out[y * width + x] = solid;
}
}
return out;
}
export function applyMaskAlpha(img: PixelImage, mask: Mask): PixelImage {
const out = createPixelImage(img.width, img.height);
for (let i = 0; i < mask.length; i++) {
const di = i * 4;
if (mask[i] === 1) {
out.data[di] = img.data[di];
out.data[di + 1] = img.data[di + 1];
out.data[di + 2] = img.data[di + 2];
out.data[di + 3] = 255;
} else {
out.data[di + 3] = 0;
}
}
return out;
}
export function dilateImage(img: PixelImage, radiusPx: number): PixelImage {
if (radiusPx < 1) return clonePixelImage(img);
return applyMaskAlpha(img, dilateMask(buildAlphaMask(img), img.width, img.height, radiusPx));
}
export function erodeImage(img: PixelImage, radiusPx: number): PixelImage {
if (radiusPx < 1) return clonePixelImage(img);
return applyMaskAlpha(img, erodeMask(buildAlphaMask(img), img.width, img.height, radiusPx));
}
export function strokeImage(
img: PixelImage,
radiusPx: number,
colorHex: string
): PixelImage {
const r = Math.trunc(radiusPx);
if (r < 1) return clonePixelImage(img);
const [cr, cg, cb] = parseHex(colorHex);
const mask = buildAlphaMask(img);
const ring = dilateMask(mask, img.width, img.height, r);
const out = createPixelImage(img.width, img.height);
for (let i = 0; i < ring.length; i++) {
const di = i * 4;
if (ring[i] === 1 && mask[i] === 0) {
out.data[di] = cr;
out.data[di + 1] = cg;
out.data[di + 2] = cb;
out.data[di + 3] = 255;
} else if (mask[i] === 1) {
out.data[di] = img.data[di];
out.data[di + 1] = img.data[di + 1];
out.data[di + 2] = img.data[di + 2];
out.data[di + 3] = img.data[di + 3];
}
}
return out;
}
export function contourImage(img: PixelImage, radiusPx: number, colorHex: string): PixelImage {
const r = Math.max(1, Math.trunc(radiusPx));
const [cr, cg, cb] = parseHex(colorHex);
const mask = buildAlphaMask(img);
const inner = erodeMask(mask, img.width, img.height, r);
const line = new Uint8Array(mask.length);
for (let i = 0; i < mask.length; i++) {
line[i] = mask[i] === 1 && inner[i] === 0 ? 1 : 0;
}
const out = createPixelImage(img.width, img.height);
for (let i = 0; i < line.length; i++) {
const di = i * 4;
if (line[i] === 1) {
out.data[di] = cr;
out.data[di + 1] = cg;
out.data[di + 2] = cb;
out.data[di + 3] = 255;
}
}
return out;
}
export function openingMask(mask: Mask, w: number, h: number, radius: number): Mask {
return dilateMask(erodeMask(mask, w, h, radius), w, h, radius);
}
export function closingMask(mask: Mask, w: number, h: number, radius: number): Mask {
return erodeMask(dilateMask(mask, w, h, radius), w, h, radius);
}
export function openingImage(img: PixelImage, radiusPx: number): PixelImage {
if (radiusPx < 1) return clonePixelImage(img);
const mask = buildAlphaMask(img);
const opened = openingMask(mask, img.width, img.height, radiusPx);
const out = createPixelImage(img.width, img.height);
for (let i = 0; i < opened.length; i++) {
const di = i * 4;
out.data[di] = img.data[di];
out.data[di + 1] = img.data[di + 1];
out.data[di + 2] = img.data[di + 2];
out.data[di + 3] = opened[i] === 1 ? img.data[di + 3] : 0;
}
return out;
}
export function closingImage(img: PixelImage, radiusPx: number): PixelImage {
if (radiusPx < 1) return clonePixelImage(img);
const mask = buildAlphaMask(img);
const closed = closingMask(mask, img.width, img.height, radiusPx);
const out = createPixelImage(img.width, img.height);
for (let i = 0; i < closed.length; i++) {
const di = i * 4;
if (closed[i] === 1 && mask[i] === 0) {
out.data[di] = 0;
out.data[di + 1] = 0;
out.data[di + 2] = 0;
} else {
out.data[di] = img.data[di];
out.data[di + 1] = img.data[di + 1];
out.data[di + 2] = img.data[di + 2];
}
out.data[di + 3] = closed[i] === 1 ? 255 : img.data[di + 3];
}
return out;
}
+18 -39
View File
@@ -1,5 +1,6 @@
import { describe, expect, it } from 'vitest';
import { CATEGORIES } from './categories';
import { TOOL_ICONS } from './tools/tool-icons';
import {
defaultParams,
getTool,
@@ -10,46 +11,7 @@ import {
type ToolEntry
} from './registry';
const PLAN_TOOL_IDS = [
'resize-png',
'crop-png',
'rotate-png',
'flip-png',
'grayscale-png',
'invert-colors-png',
'adjust-brightness-contrast-png',
'convert-png-to-jpg',
'convert-png-to-webp',
'remove-color-from-png',
'png-info',
'invert-alpha-png',
'jpg-to-png',
'webp-to-png',
'gif-to-png',
'bmp-to-png',
'ico-to-png',
'png-to-bmp',
'png-to-base64',
'base64-to-png',
'png-to-data-uri',
'data-uri-to-png',
'png-to-hex',
'hex-to-png',
'change-png-opacity',
'sepia-png',
'change-png-hue',
'extract-channel-png',
'swap-channels-png',
'black-and-white-png',
'posterize-png',
'two-colors-png'
];
describe('реестр инструментов', () => {
it('содержит ровно 32 инструмента из плана', () => {
expect(TOOLS.map((t) => t.id).sort()).toEqual([...PLAN_TOOL_IDS].sort());
});
it('id уникальны и в kebab-case', () => {
const ids = TOOLS.map((t) => t.id);
expect(new Set(ids).size).toBe(ids.length);
@@ -65,6 +27,8 @@ describe('реестр инструментов', () => {
it.each(TOOLS.map((t) => [t.id, t] as const))('%s: исполнители определены', (_, tool) => {
if (tool.resultType === 'text') {
expect(typeof tool.toText).toBe('function');
} else if (tool.sourceMode === 'none') {
expect(typeof tool.generate).toBe('function');
} else {
expect(typeof tool.run).toBe('function');
}
@@ -129,6 +93,21 @@ describe('реестр инструментов', () => {
const resize = getToolOrThrow('resize-png');
expect(defaultParams(resize)).toEqual({ width: 0, height: 0, keepAspect: true });
});
it('словарь иконок покрывает все инструменты', () => {
for (const tool of TOOLS) {
expect(TOOL_ICONS[tool.id]).toBeDefined();
}
});
it('у каждого инструмента популярность в диапазоне 0..100 и ключ иконки', () => {
for (const tool of TOOLS) {
expect(tool.popularity).toBeGreaterThanOrEqual(0);
expect(tool.popularity).toBeLessThanOrEqual(100);
expect(typeof tool.icon).toBe('string');
expect(tool.icon!.length).toBeGreaterThan(0);
}
});
});
describe('sanitizeParams', () => {
+427 -3
View File
@@ -1,5 +1,33 @@
import type { CategoryId } from './categories';
import { colorMask, flattenOntoColor, invertAlpha, removeColorToAlpha } from './core/alpha';
import {
colorMask,
extractAlphaMask,
flattenOntoColor,
hardenAlpha,
invertAlpha,
removeColorToAlpha,
roundCorners,
setAlphaChannel
} from './core/alpha';
import {
hasTransparency,
isGrayscale,
orientationOf
} from './core/analyze';
import {
backgroundMaskPreview,
removeBackground
} from './core/background';
import {
closingImage,
contourImage,
dilateImage,
erodeImage,
openingImage,
strokeImage
} from './core/morphology';
import { gaussianBlur, sharpen as sharpenImage } from './core/convolution';
import { gradientImage, noiseImage, solidImage } from './core/generate';
import {
brightnessContrast,
changeHue,
@@ -15,7 +43,7 @@ import {
type ChannelSwapPair,
type RgbChannel
} from './core/color';
import { crop, flip, resize, rotate90 } from './core/geometry';
import { centerByAlpha, crop, expandCanvas, flip, resize, rotate90, tile } from './core/geometry';
import { decodeTextImage, toBase64, toDataUrl, type OutputMime } from './core/io';
import { hexToPixels, pixelsToHex } from './core/text';
import { clonePixelImage, type PixelImage } from './core/types';
@@ -72,11 +100,17 @@ export type ToolEntry = {
img: PixelImage,
params: Record<string, unknown>
) => Promise<PixelImage> | PixelImage;
popularity?: number;
icon?: string;
resultType?: 'image' | 'info' | 'text';
output?: OutputFormat;
};
const PNG_OUTPUT: OutputFormat = { mime: 'image/png', ext: 'png' };
export const PNG_OUTPUT: OutputFormat = { mime: 'image/png', ext: 'png' };
export function isChainable(tool: ToolEntry): boolean {
return (tool.resultType ?? 'image') === 'image' && (!tool.sourceMode || tool.sourceMode === 'file');
}
function num(params: Record<string, unknown>, id: string): number {
const v = params[id];
@@ -105,6 +139,20 @@ function decodeToPng(id: string, title: string, description: string): ToolEntry
};
}
function hexToRgba(hex: string, alpha = 255): [number, number, number, number] {
const match = /^#([0-9a-f]{6})$/i.exec(hex.trim());
if (!match) {
throw new Error(`Некорректный HEX-цвет: "${hex}"`);
}
const d = match[1];
return [
parseInt(d.slice(0, 2), 16),
parseInt(d.slice(2, 4), 16),
parseInt(d.slice(4, 6), 16),
alpha
];
}
export const TOOLS: ToolEntry[] = [
decodeToPng(
'jpg-to-png',
@@ -293,6 +341,109 @@ export const TOOLS: ToolEntry[] = [
],
run: (img, p) => flip(img, str(p, 'axis') === 'vertical' ? 'vertical' : 'horizontal')
},
{
id: 'add-padding-png',
title: 'Добавить поля PNG',
description: 'Расширяет холст во все стороны на выбранное число пикселей.',
category: 'geometry',
params: [
{ id: 'padding', label: 'Поля, px', type: 'number', min: 1, max: 2000, step: 1, default: 10 },
{ id: 'transparent', label: 'Прозрачные поля', type: 'checkbox', default: true },
{ id: 'color', label: 'Цвет полей', type: 'color', default: '#ffffff' }
],
run: (img, p) =>
expandCanvas(
img,
num(p, 'padding'),
num(p, 'padding'),
num(p, 'padding'),
num(p, 'padding'),
p['transparent'] === true ? undefined : str(p, 'color')
)
},
{
id: 'add-border-png',
title: 'Добавить рамку PNG',
description: 'Рисует цветную рамку вокруг изображения выбранной толщины.',
category: 'geometry',
params: [
{ id: 'thickness', label: 'Толщина рамки, px', type: 'number', min: 1, max: 500, step: 1, default: 5 },
{ id: 'color', label: 'Цвет рамки', type: 'color', default: '#000000' }
],
run: (img, p) => expandCanvas(img, num(p, 'thickness'), num(p, 'thickness'), num(p, 'thickness'), num(p, 'thickness'), str(p, 'color'))
},
{
id: 'fit-on-background-png',
title: 'Вписать PNG на фон',
description:
'Помещает изображение по центру полотна заданного размера с прозрачным или цветным фоном.',
category: 'geometry',
params: [
{ id: 'width', label: 'Ширина полотна', type: 'number', min: 1, max: 20000, step: 1, default: 800 },
{ id: 'height', label: 'Высота полотна', type: 'number', min: 1, max: 20000, step: 1, default: 600 },
{ id: 'transparent', label: 'Прозрачный фон', type: 'checkbox', default: false },
{ id: 'color', label: 'Цвет фона', type: 'color', default: '#ffffff' }
],
run: (img, p) => {
const width = Math.trunc(num(p, 'width'));
const height = Math.trunc(num(p, 'height'));
if (width <= 0 || height <= 0) {
throw new Error('Укажите положительные размеры полотна');
}
const left = Math.max(0, Math.floor((width - img.width) / 2));
const top = Math.max(0, Math.floor((height - img.height) / 2));
return expandCanvas(
img,
left,
top,
Math.max(0, width - img.width - left),
Math.max(0, height - img.height - top),
p['transparent'] === true ? undefined : str(p, 'color')
);
}
},
{
id: 'tile-png',
title: 'Замостить PNG',
description: 'Повторяет изображение сеткой из выбранного числа столбцов и строк.',
category: 'geometry',
params: [
{ id: 'columns', label: 'Столбцов', type: 'number', min: 1, max: 50, step: 1, default: 2 },
{ id: 'rows', label: 'Строк', type: 'number', min: 1, max: 50, step: 1, default: 2 }
],
run: (img, p) => tile(img, num(p, 'columns'), num(p, 'rows'))
},
{
id: 'center-by-alpha-png',
title: 'Центрировать PNG по содержимому',
description:
'Находит непрозрачную часть изображения и размещает её по центру прежнего холста.',
category: 'geometry',
params: [],
run: (img) => centerByAlpha(img)
},
{
id: 'blur-png',
title: 'Размытие PNG',
description:
'Гауссово размытие: три прохода разделяемого бокса — быстро при любом радиусе. Прозрачные края не темнеют.',
category: 'filters',
params: [
{ id: 'radius', label: 'Радиус, px', type: 'slider', min: 1, max: 32, step: 1, default: 4 }
],
run: (img, p) => gaussianBlur(img, num(p, 'radius'))
},
{
id: 'sharpen-png',
title: 'Резкость PNG',
description:
'Подчёркивает края ядром резкости; сила задаёт смесь с оригиналом. 0% — без изменений.',
category: 'filters',
params: [
{ id: 'strength', label: 'Сила, %', type: 'slider', min: 0, max: 100, step: 1, default: 50 }
],
run: (img, p) => sharpenImage(img, num(p, 'strength'))
},
{
id: 'grayscale-png',
title: 'Чёрно-белый PNG',
@@ -443,6 +594,43 @@ export const TOOLS: ToolEntry[] = [
output: { mime: 'image/webp', ext: 'webp', qualityParamId: 'quality' },
run: (img) => clonePixelImage(img)
},
{
id: 'remove-alpha-channel-png',
title: 'Убрать альфа-канал PNG',
description: 'Накладывает изображение на белый фон и сохраняет без прозрачности.',
category: 'alpha',
params: [],
run: (img) => flattenOntoColor(img, '#ffffff')
},
{
id: 'set-alpha-channel-png',
title: 'Задать альфа-канал PNG',
description: 'Присваивает всем пикселям одинаковую прозрачность, цвета не меняются.',
category: 'alpha',
params: [
{ id: 'percent', label: 'Прозрачность, %', type: 'slider', min: 0, max: 100, step: 1, default: 100 }
],
run: (img, p) => setAlphaChannel(img, num(p, 'percent'))
},
{
id: 'extract-alpha-mask-png',
title: 'Извлечь маску альфы PNG',
description: 'Превращает прозрачность в чёрно-белую непрозрачную маску.',
category: 'alpha',
params: [],
run: (img) => extractAlphaMask(img)
},
{
id: 'round-corners-png',
title: 'Скруглить углы PNG',
description:
'Обрезает углы по радиусу, заданному в процентах от половины меньшей стороны.',
category: 'alpha',
params: [
{ id: 'radius', label: 'Радиус скругления, %', type: 'slider', min: 0, max: 50, step: 1, default: 10 }
],
run: (img, p) => roundCorners(img, num(p, 'radius'))
},
{
id: 'invert-alpha-png',
title: 'Инвертировать альфа-канал PNG',
@@ -451,6 +639,108 @@ export const TOOLS: ToolEntry[] = [
params: [],
run: (img) => invertAlpha(img)
},
{
id: 'remove-background-png',
title: 'Удалить фон PNG (умно)',
description:
'Убирает однотонный фон: по цвету с допуском, только внешние области от краёв или весь совпадающий цвет. Умеет сглаживать границу.',
category: 'alpha',
params: [
{ id: 'color', label: 'Цвет фона', type: 'color', default: '#ffffff' },
{ id: 'tolerance', label: 'Допуск похожести, %', type: 'slider', min: 0, max: 100, step: 1, default: 10 },
{ id: 'outerOnly', label: 'Только внешние области', type: 'checkbox', default: true },
{ id: 'smooth', label: 'Сглаживание границы, проходы', type: 'slider', min: 0, max: 8, step: 1, default: 1 }
],
run: (img, p) =>
removeBackground(img, {
color: str(p, 'color'),
tolerancePercent: num(p, 'tolerance'),
outerOnly: p['outerOnly'] === true,
smoothPasses: num(p, 'smooth')
}),
preview: (img, p) =>
backgroundMaskPreview(img, {
color: str(p, 'color'),
tolerancePercent: num(p, 'tolerance'),
outerOnly: p['outerOnly'] === true,
smoothPasses: num(p, 'smooth')
})
},
{
id: 'add-stroke-png',
title: 'Обвести PNG',
description:
'Добавляет цветную обводку-кольцо вокруг непрозрачного содержимого заданной толщины.',
category: 'alpha',
params: [
{ id: 'color', label: 'Цвет обводки', type: 'color', default: '#ff0000' },
{ id: 'thickness', label: 'Толщина, px', type: 'slider', min: 1, max: 10, step: 1, default: 3 }
],
run: (img, p) => strokeImage(img, num(p, 'thickness'), str(p, 'color'))
},
{
id: 'find-contour-png',
title: 'Найти контур PNG',
description:
'Оставляет только линию по границе непрозрачных областей выбранного цвета и толщины.',
category: 'alpha',
params: [
{ id: 'color', label: 'Цвет линии', type: 'color', default: '#000000' },
{ id: 'thickness', label: 'Толщина линии, px', type: 'slider', min: 1, max: 5, step: 1, default: 1 }
],
run: (img, p) => contourImage(img, num(p, 'thickness'), str(p, 'color'))
},
{
id: 'make-thicker-png',
title: 'Утолщить PNG',
description: 'Расширяет непрозрачные области на заданное число пикселей.',
category: 'alpha',
params: [
{ id: 'radius', label: 'На сколько px', type: 'slider', min: 1, max: 10, step: 1, default: 2 }
],
run: (img, p) => dilateImage(img, num(p, 'radius'))
},
{
id: 'make-thinner-png',
title: 'Утончить PNG',
description: 'Сужает непрозрачные области — утоньшает штрихи надписей и деталей.',
category: 'alpha',
params: [
{ id: 'radius', label: 'На сколько px', type: 'slider', min: 1, max: 10, step: 1, default: 1 }
],
run: (img, p) => erodeImage(img, num(p, 'radius'))
},
{
id: 'harden-alpha-png',
title: 'Жёсткие края PNG',
description:
'Бинаризует альфа-канал по порогу: полупрозрачные пиксели становятся либо полностью прозрачными, либо непрозрачными.',
category: 'alpha',
params: [
{ id: 'threshold', label: 'Порог альфы, %', type: 'slider', min: 0, max: 100, step: 1, default: 50 }
],
run: (img, p) => hardenAlpha(img, num(p, 'threshold'))
},
{
id: 'despeckle-alpha-png',
title: 'Убрать мусор PNG',
description: 'Открытие: убирает одиночные полупрозрачные пиксели и мелкие крапинки.',
category: 'alpha',
params: [
{ id: 'radius', label: 'Радиус очистки, px', type: 'slider', min: 1, max: 3, step: 1, default: 1 }
],
run: (img, p) => openingImage(img, num(p, 'radius'))
},
{
id: 'close-holes-png',
title: 'Закрыть дыры PNG',
description: 'Закрытие: заполняет одиночные прозрачные точки внутри объекта.',
category: 'alpha',
params: [
{ id: 'radius', label: 'Радиус закрытия, px', type: 'slider', min: 1, max: 3, step: 1, default: 1 }
],
run: (img, p) => closingImage(img, num(p, 'radius'))
},
{
id: 'remove-color-from-png',
title: 'Удалить цвет из PNG (прозрачность)',
@@ -473,9 +763,143 @@ export const TOOLS: ToolEntry[] = [
params: [],
resultType: 'info',
run: (img) => clonePixelImage(img)
},
{
id: 'create-empty-png',
title: 'Создать пустой PNG',
description: 'Генерирует холст выбранного размера — прозрачный или залитый цветом.',
category: 'generate',
sourceMode: 'none',
params: [
{ id: 'width', label: 'Ширина', type: 'number', min: 1, max: 20000, step: 1, default: 800 },
{ id: 'height', label: 'Высота', type: 'number', min: 1, max: 20000, step: 1, default: 600 },
{ id: 'transparent', label: 'Прозрачный', type: 'checkbox', default: true },
{ id: 'color', label: 'Цвет', type: 'color', default: '#ffffff' }
],
generate: (p) =>
solidImage(
Math.trunc(num(p, 'width')),
Math.trunc(num(p, 'height')),
p['transparent'] === true
? [0, 0, 0, 0]
: hexToRgba(str(p, 'color'))
)
},
{
id: 'single-color-png',
title: 'Создать одноцветный PNG',
description: 'Генерирует прямоугольник заданного размера и цвета.',
category: 'generate',
sourceMode: 'none',
params: [
{ id: 'width', label: 'Ширина', type: 'number', min: 1, max: 20000, step: 1, default: 256 },
{ id: 'height', label: 'Высота', type: 'number', min: 1, max: 20000, step: 1, default: 256 },
{ id: 'color', label: 'Цвет', type: 'color', default: '#ff0000' }
],
generate: (p) =>
solidImage(
Math.trunc(num(p, 'width')),
Math.trunc(num(p, 'height')),
hexToRgba(str(p, 'color'))
)
},
{
id: 'random-noise-png',
title: 'Создать случайный шум PNG',
description:
'Генерирует картинку со случайными пикселями. Зерно фиксирует результат: одно зерно — одна картинка.',
category: 'generate',
sourceMode: 'none',
params: [
{ id: 'width', label: 'Ширина', type: 'number', min: 1, max: 5000, step: 1, default: 512 },
{ id: 'height', label: 'Высота', type: 'number', min: 1, max: 5000, step: 1, default: 512 },
{ id: 'seed', label: 'Зерно', type: 'number', min: 0, max: 999999999, step: 1, default: 1 }
],
generate: (p) => noiseImage(Math.trunc(num(p, 'width')), Math.trunc(num(p, 'height')), num(p, 'seed'))
},
{
id: 'linear-gradient-png',
title: 'Создать градиент PNG',
description: 'Генерирует плавный переход между двумя цветами по горизонтали или вертикали.',
category: 'generate',
sourceMode: 'none',
params: [
{ id: 'width', label: 'Ширина', type: 'number', min: 1, max: 20000, step: 1, default: 800 },
{ id: 'height', label: 'Высота', type: 'number', min: 1, max: 20000, step: 1, default: 600 },
{ id: 'fromColor', label: 'Цвет начала', type: 'color', default: '#000000' },
{ id: 'toColor', label: 'Цвет конца', type: 'color', default: '#ffffff' },
{
id: 'direction',
label: 'Направление',
type: 'select',
default: 'horizontal',
options: [
{ value: 'horizontal', label: 'По горизонтали' },
{ value: 'vertical', label: 'По вертикали' }
]
}
],
generate: (p) =>
gradientImage(
Math.trunc(num(p, 'width')),
Math.trunc(num(p, 'height')),
hexToRgba(str(p, 'fromColor')),
hexToRgba(str(p, 'toColor')),
str(p, 'direction') === 'vertical' ? 'vertical' : 'horizontal'
)
},
{
id: 'png-is-grayscale',
title: 'Проверить: PNG монохромный?',
description: 'Сообщает, состоит ли изображение только из оттенков серого.',
category: 'analyze',
params: [],
resultType: 'text',
toText: (img) =>
isGrayscale(img)
? 'Да — все пиксели являются оттенками серого.'
: 'Нет — найдены цветные пиксели.'
},
{
id: 'png-is-transparent',
title: 'Проверить: PNG прозрачный?',
description: 'Сообщает, есть ли в изображении прозрачные или полупрозрачные пиксели.',
category: 'analyze',
params: [],
resultType: 'text',
toText: (img) =>
hasTransparency(img)
? 'Да — есть прозрачные или полупрозрачные пиксели.'
: 'Нет — все пиксели полностью непрозрачны.'
},
{
id: 'png-orientation',
title: 'Ориентация PNG',
description: 'Сообщает, портрет это, ландшафт или квадрат.',
category: 'analyze',
params: [],
resultType: 'text',
toText: (img) => {
switch (orientationOf(img)) {
case 'portrait':
return 'Портрет — высота больше ширины.';
case 'landscape':
return 'Ландшафт — ширина больше высоты.';
default:
return 'Квадрат — стороны равны.';
}
}
}
];
import { TOOL_ICONS } from './tools/tool-icons';
import { TOOL_POPULARITY } from './tools/tool-popularity';
for (const entry of TOOLS) {
entry.icon = entry.id;
entry.popularity = TOOL_POPULARITY[entry.id] ?? 50;
}
export function getTool(id: string): ToolEntry | undefined {
return TOOLS.find((tool) => tool.id === id);
}
+26
View File
@@ -0,0 +1,26 @@
export function createAutoRunner(delayMs = 300) {
let token = 0;
let timer: ReturnType<typeof setTimeout> | undefined;
return {
next(): number {
cancelTimer();
return ++token;
},
isCurrent(t: number): boolean {
return t === token;
},
schedule(run: () => void): () => void {
cancelTimer();
timer = setTimeout(run, delayMs);
return cancelTimer;
}
};
function cancelTimer() {
if (timer !== undefined) {
clearTimeout(timer);
timer = undefined;
}
}
}
+37
View File
@@ -0,0 +1,37 @@
import { describe, expect, it } from 'vitest';
import { executeStep } from './executor';
import { makeImage } from '../core/test-helpers';
import type { PixelImage } from '../core/types';
function cloneOf(img: PixelImage): PixelImage {
return { width: img.width, height: img.height, data: new Uint8ClampedArray(img.data) };
}
describe('executeStep: прямой путь (среда без Worker)', () => {
it('возвращает результат инструмента', async () => {
const img = makeImage(1, 1, [
[1, 2, 3, 4]
]);
const out = await executeStep(
{ id: 'stub', run: (i) => cloneOf(i) },
img,
{}
);
expect(out).toEqual(img);
});
it('пробрасывает ошибку инструмента как есть', async () => {
const boom = () => {
throw new Error('бум');
};
await expect(executeStep({ id: 'stub', run: boom }, makeImage(1, 1, [[0, 0, 0, 255]]), {})).rejects.toThrow(
'бум'
);
});
it('инструмент без run даёт понятную ошибку', async () => {
await expect(executeStep({ id: 'stub' }, makeImage(1, 1, [[0, 0, 0, 255]]), {})).rejects.toThrow(
'не обрабатывает изображения'
);
});
});
+117
View File
@@ -0,0 +1,117 @@
import type { PixelImage } from '../core/types';
type MaybeRunnable = {
id: string;
run?: (img: PixelImage, params: Record<string, unknown>) => Promise<PixelImage> | PixelImage;
};
export async function executeStep(
tool: MaybeRunnable,
img: PixelImage,
params: Record<string, unknown>
): Promise<PixelImage> {
if (!tool.run) {
throw new Error('Этот инструмент не обрабатывает изображения');
}
if (typeof Worker === 'undefined') {
return await runDirect(tool, img, params);
}
const worker = ensureWorker();
if (worker === null) {
return await runDirect(tool, img, params);
}
try {
return await runInWorker(worker, tool.id, img, params);
} catch (workerError) {
disableWorker();
void workerError;
return await runDirect(tool, img, params);
}
}
async function runDirect(
tool: MaybeRunnable,
img: PixelImage,
params: Record<string, unknown>
): Promise<PixelImage> {
if (!tool.run) {
throw new Error('Этот инструмент не обрабатывает изображения');
}
return await tool.run(img, params);
}
let worker: Worker | null = null;
let workerTried = false;
let nextRequestId = 1;
const pending = new Map<
number,
{ resolve: (img: PixelImage) => void; reject: (e: Error) => void }
>();
function ensureWorker(): Worker | null {
if (workerTried) return worker;
workerTried = true;
try {
const candidate = new Worker(new URL('./executor.worker.ts', import.meta.url), {
type: 'module'
});
candidate.onmessage = (event: MessageEvent) => {
const payload = event.data as {
id: number;
ok: boolean;
width?: number;
height?: number;
data?: Uint8ClampedArray;
error?: string;
};
const entry = pending.get(payload.id);
if (!entry) return;
pending.delete(payload.id);
if (payload.ok && payload.data && payload.width && payload.height) {
entry.resolve({
width: payload.width,
height: payload.height,
data: new Uint8ClampedArray(payload.data)
});
} else {
entry.reject(new Error(payload.error ?? 'Ошибка исполнения в воркере'));
}
};
candidate.onerror = () => {
worker = null;
for (const entry of pending.values()) {
entry.reject(new Error('Воркер недоступен'));
}
pending.clear();
};
worker = candidate;
} catch {
worker = null;
}
return worker;
}
function disableWorker(): void {
if (worker) {
worker.terminate();
}
worker = null;
}
function runInWorker(
workerInstance: Worker,
toolId: string,
img: PixelImage,
params: Record<string, unknown>
): Promise<PixelImage> {
return new Promise((resolve, reject) => {
const id = nextRequestId++;
pending.set(id, { resolve, reject });
workerInstance.postMessage({
id,
toolId,
image: { width: img.width, height: img.height, data: new Uint8ClampedArray(img.data) },
params
});
});
}
+43
View File
@@ -0,0 +1,43 @@
/// <reference lib="webworker" />
import type { PixelImage } from '../core/types';
import { getTool, sanitizeParams } from '../registry';
type WorkerRequest = {
id: number;
toolId: string;
image: { width: number; height: number; data: Uint8ClampedArray };
params: Record<string, unknown>;
};
self.onmessage = (event: MessageEvent<WorkerRequest>) => {
void handle(event.data);
};
async function handle(request: WorkerRequest): Promise<void> {
try {
const tool = getTool(request.toolId);
if (!tool?.run) {
throw new Error('Этот инструмент не обрабатывает изображения');
}
const image: PixelImage = {
width: request.image.width,
height: request.image.height,
data: new Uint8ClampedArray(request.image.data)
};
const output = await tool.run(image, sanitizeParams(tool, request.params));
const payload = {
id: request.id,
ok: true,
width: output.width,
height: output.height,
data: output.data
};
(self as unknown as Worker).postMessage(payload, [output.data.buffer]);
} catch (e) {
(self as unknown as Worker).postMessage({
id: request.id,
ok: false,
error: e instanceof Error ? e.message : String(e)
});
}
}
+54
View File
@@ -0,0 +1,54 @@
import { describe, expect, it } from 'vitest';
import { createStep, parseDocument } from './pipeline';
describe('createStep', () => {
it('создаёт шаг с дефолтными значениями инструмента', () => {
const step = createStep('grayscale-png');
expect(step.toolId).toBe('grayscale-png');
expect(step.id.length).toBeGreaterThan(0);
});
it('бросает ошибку для неизвестного инструмента', () => {
expect(() => createStep('no-such-tool')).toThrow(/не найден/);
});
});
describe('parseDocument', () => {
const valid = JSON.stringify({
version: 1,
steps: [
{ id: 'a', toolId: 'grayscale-png', values: {} },
{
id: 'b',
toolId: 'adjust-brightness-contrast-png',
values: { brightness: 5000, contrast: 'мусор' }
}
]
});
it('разбирает корректный документ и санитизирует значения', () => {
const steps = parseDocument(valid);
expect(steps).toHaveLength(2);
expect(steps[0].id).toBe('a');
expect(steps[1].values).toEqual({ brightness: 100, contrast: 0 });
});
it.each(['не json', '{}', JSON.stringify({ version: 99, steps: [] }), JSON.stringify({ version: 1 })])(
'отклоняет мусор: %s',
(raw) => {
expect(() => parseDocument(raw as string)).toThrow();
}
);
it('пропускает шаги с неизвестным инструментом', () => {
const raw = JSON.stringify({
version: 1,
steps: [
{ id: 'x', toolId: 'nope', values: {} },
{ id: 'y', toolId: 'flip-png', values: { axis: 'vertical' } }
]
});
const steps = parseDocument(raw);
expect(steps.map((s) => s.id)).toEqual(['y']);
});
});
+91
View File
@@ -0,0 +1,91 @@
import { getTool, sanitizeParams } from '../registry';
export type PipelineStep = {
id: string;
toolId: string;
values: Record<string, unknown>;
};
export type PipelineDocument = { version: number; steps: PipelineStep[] };
export const PIPELINE_VERSION = 1;
const STORAGE_KEY = 'workspace-pipeline';
export function createStep(toolId: string): PipelineStep {
const tool = getTool(toolId);
if (!tool) {
throw new Error(`Инструмент "${toolId}" не найден`);
}
return { id: newStepId(), toolId, values: {} };
}
export function newStepId(): string {
if (typeof crypto !== 'undefined' && 'randomUUID' in crypto) {
return crypto.randomUUID();
}
return `step-${Date.now().toString(36)}-${Math.floor(Math.random() * 1e9).toString(36)}`;
}
export function parseDocument(raw: string): PipelineStep[] {
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch {
throw new Error('Файл не является корректным JSON');
}
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
throw new Error('Структура файла не похожа на цепочку шагов');
}
const doc = parsed as { version?: unknown; steps?: unknown };
if (doc.version !== PIPELINE_VERSION) {
throw new Error(`Неподдерживаемая версия цепочки: ${String(doc.version)}`);
}
if (!Array.isArray(doc.steps)) {
throw new Error('В файле нет списка шагов');
}
const steps: PipelineStep[] = [];
for (const rawStep of doc.steps) {
const step = validateStep(rawStep);
if (step) {
steps.push(step);
}
}
return steps;
}
function validateStep(raw: unknown): PipelineStep | null {
if (typeof raw !== 'object' || raw === null) return null;
const candidate = raw as { id?: unknown; toolId?: unknown; values?: unknown };
if (typeof candidate.toolId !== 'string') return null;
const tool = getTool(candidate.toolId);
if (!tool || typeof candidate.values !== 'object' || candidate.values === null) return null;
const values = candidate.values as Record<string, unknown>;
return {
id: typeof candidate.id === 'string' && candidate.id.length > 0 ? candidate.id : newStepId(),
toolId: candidate.toolId,
values: sanitizeParams(tool, values)
};
}
export function saveSteps(steps: PipelineStep[]): void {
if (typeof localStorage === 'undefined') return;
try {
localStorage.setItem(
STORAGE_KEY,
JSON.stringify({ version: PIPELINE_VERSION, steps })
);
} catch {
// переполнение квоты или приватный режим — сохранение необязательно для работы
}
}
export function loadStoredSteps(): PipelineStep[] {
if (typeof localStorage === 'undefined') return [];
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return [];
return parseDocument(raw);
} catch {
return [];
}
}
+103
View File
@@ -0,0 +1,103 @@
import {
AppWindow,
Binary,
Blend,
ClipboardPaste,
Contrast,
Crop,
Crosshair,
Dices,
Droplet,
Droplets,
Eye,
FileImage,
FileOutput,
FilePlus2,
Film,
FlipHorizontal2,
Focus,
Frame,
Grid3x3,
Hammer,
Hash,
Image as ImageIcon,
Info,
Layers,
Link2,
Maximize2,
PaintBucket,
Palette,
Rainbow,
Repeat2,
RotateCw,
Ruler,
Scaling,
Scan,
Scissors,
SearchCheck,
Square,
Sun,
ZoomIn,
ZoomOut
} from '@lucide/svelte';
export const TOOL_ICONS: Record<string, typeof AppWindow> = {
'resize-png': Scaling,
'crop-png': Crop,
'rotate-png': RotateCw,
'flip-png': FlipHorizontal2,
'grayscale-png': Contrast,
'invert-colors-png': Blend,
'adjust-brightness-contrast-png': Sun,
'convert-png-to-jpg': FileImage,
'convert-png-to-webp': FileImage,
'remove-color-from-png': Scissors,
'png-info': Info,
'jpg-to-png': FileImage,
'webp-to-png': FileImage,
'gif-to-png': Film,
'bmp-to-png': FileImage,
'ico-to-png': AppWindow,
'png-to-bmp': FileOutput,
'png-to-base64': Binary,
'base64-to-png': ClipboardPaste,
'png-to-data-uri': Link2,
'data-uri-to-png': ClipboardPaste,
'png-to-hex': Hash,
'hex-to-png': Hash,
'change-png-opacity': Droplet,
'sepia-png': Palette,
'change-png-hue': Rainbow,
'extract-channel-png': Layers,
'swap-channels-png': Repeat2,
'black-and-white-png': Contrast,
'posterize-png': Layers,
'two-colors-png': Blend,
'invert-alpha-png': Droplets,
'remove-alpha-channel-png': Square,
'set-alpha-channel-png': Droplet,
'extract-alpha-mask-png': Layers,
'round-corners-png': Frame,
'add-padding-png': Maximize2,
'add-border-png': Frame,
'fit-on-background-png': ImageIcon,
'tile-png': Grid3x3,
'center-by-alpha-png': Crosshair,
'create-empty-png': FilePlus2,
'single-color-png': PaintBucket,
'random-noise-png': Dices,
'linear-gradient-png': Sun,
'png-is-grayscale': SearchCheck,
'png-is-transparent': Eye,
'png-orientation': Ruler,
'blur-png': Droplets,
'sharpen-png': Focus,
'remove-background-png': Scissors,
'add-stroke-png': Square,
'find-contour-png': Scan,
'make-thicker-png': ZoomIn,
'make-thinner-png': ZoomOut,
'harden-alpha-png': Sun,
'despeckle-alpha-png': SearchCheck,
'close-holes-png': Hammer
};
+53
View File
@@ -0,0 +1,53 @@
export const TOOL_POPULARITY: Record<string, number> = {
'crop-png': 92,
'resize-png': 90,
'remove-background-png': 95,
'blur-png': 85,
'sharpen-png': 80,
'convert-png-to-jpg': 82,
'jpg-to-png': 78,
'rotate-png': 75,
'flip-png': 70,
'convert-png-to-webp': 68,
'webp-to-png': 62,
'grayscale-png': 60,
'adjust-brightness-contrast-png': 58,
'invert-colors-png': 52,
'remove-color-from-png': 65,
'change-png-opacity': 45,
'sepia-png': 40,
'change-png-hue': 38,
'add-padding-png': 42,
'add-border-png': 38,
'two-colors-png': 35,
'black-and-white-png': 42,
'posterize-png': 30,
'round-corners-png': 36,
'fit-on-background-png': 34,
'extract-channel-png': 24,
'swap-channels-png': 22,
'invert-alpha-png': 26,
'remove-alpha-channel-png': 30,
'set-alpha-channel-png': 24,
'extract-alpha-mask-png': 20,
'tile-png': 28,
'center-by-alpha-png': 26,
'png-info': 44,
'png-is-transparent': 18,
'png-is-grayscale': 14,
'png-orientation': 16,
'png-to-base64': 40,
'base64-to-png': 32,
'png-to-data-uri': 34,
'data-uri-to-png': 26,
'png-to-hex': 10,
'hex-to-png': 8,
'gif-to-png': 30,
'bmp-to-png': 18,
'ico-to-png': 20,
'png-to-bmp': 12,
'create-empty-png': 25,
'single-color-png': 20,
'random-noise-png': 12,
'linear-gradient-png': 22
};
+8 -5
View File
@@ -1,7 +1,6 @@
<script lang="ts">
import '../app.css';
import favicon from '$lib/assets/favicon.svg';
import { CATEGORIES } from '$lib/categories';
let { children } = $props();
</script>
@@ -13,10 +12,9 @@
<div class="app">
<header>
<a href="/" class="brand">easy-png-tools</a>
<nav aria-label="Категории инструментов">
{#each CATEGORIES as category (category.id)}
<a class="nav-link" href="/#{category.id}">{category.label}</a>
{/each}
<nav aria-label="Разделы">
<a class="nav-link workspace-link" href="/">Рабочая область</a>
<a class="nav-link" href="/list-tools">Каталог</a>
</nav>
</header>
@@ -79,6 +77,11 @@
text-decoration: none;
}
.workspace-link {
font-weight: 600;
color: var(--accent);
}
main {
flex: 1;
width: 100%;
+82 -74
View File
@@ -1,97 +1,105 @@
<script lang="ts">
import { CATEGORIES } from '$lib/categories';
import { TOOLS } from '$lib/registry';
import { getTool } from '$lib/registry';
import ToolPage from '$lib/components/ToolPage.svelte';
import ToolSearch from '$lib/components/search/ToolSearch.svelte';
import Button from '$lib/components/ui/Button.svelte';
const LAST_TOOL_KEY = 'last-tool-id';
function loadLastToolId(): string | null {
if (typeof localStorage === 'undefined') return null;
try {
const id = localStorage.getItem(LAST_TOOL_KEY);
return id !== null && getTool(id) ? id : null;
} catch {
return null;
}
}
let selectedId = $state<string | null>(null);
let restoreOnOpen = $state(false);
let lastToolId = $state<string | null>(loadLastToolId());
function openTool(id: string, restore = false) {
if (!getTool(id)) return;
selectedId = id;
restoreOnOpen = restore;
lastToolId = id;
if (typeof localStorage === 'undefined') return;
try {
localStorage.setItem(LAST_TOOL_KEY, id);
} catch {
// приватный режим — просто не запоминаем
}
}
function closeTool() {
selectedId = null;
}
let selected = $derived(selectedId !== null ? (getTool(selectedId) ?? null) : null);
</script>
<svelte:head>
<title>easy-png-tools — PNG-утилиты прямо в браузере</title>
<meta
name="description"
content="Набор PNG-утилит, которые работают полностью локально в вашем браузере: конвертация, прозрачность, цвет, геометрия и анализ изображений."
/>
<title>
{selected ? `${selected.title} easy-png-tools` : 'easy-png-tools — PNG-утилиты прямо в браузере'}
</title>
</svelte:head>
<h1>easy-png-tools</h1>
<p class="lead text-muted">
Набор утилит для работы с PNG. Все операции выполняются локально в браузере — файлы никуда не
отправляются.
</p>
{#each CATEGORIES as category (category.id)}
{@const categoryTools = TOOLS.filter((tool) => tool.category === category.id)}
{#if categoryTools.length > 0}
<section id={category.id} class="category" aria-labelledby="{category.id}-heading">
<h2 id="{category.id}-heading" class="heading-section">{category.label}</h2>
<div class="grid">
{#each categoryTools as tool (tool.id)}
<a class="card panel" href="/tools/{tool.id}">
<span class="card-title">{tool.title}</span>
<span class="card-desc text-caption text-muted">{tool.description}</span>
</a>
{/each}
{#if !selected}
<section class="hero">
<h1>Что делаем с изображением?</h1>
<p class="lead text-muted">Найдите инструмент — все операции выполняются локально в браузере.</p>
<ToolSearch onSelect={(id) => openTool(id)} />
{#if lastToolId !== null && getTool(lastToolId)}
{@const restoreId = lastToolId}
<div class="restore-row">
<Button variant="secondary" fullWidth onclick={() => openTool(restoreId, true)}>
↩ Вернуть последний: {getTool(restoreId)?.title}
</Button>
</div>
</section>
{/if}
{/each}
{/if}
</section>
{:else}
<section class="workbench">
<button type="button" class="back text-caption text-muted" onclick={closeTool}>
← Сменить инструмент
</button>
{#key selectedId}
<ToolPage tool={selected} restoreChain={restoreOnOpen} />
{/key}
</section>
{/if}
<style>
.hero {
text-align: center;
padding-top: var(--space-5);
}
h1 {
margin-bottom: var(--space-1);
margin-bottom: var(--space-2);
}
.lead {
max-width: 48rem;
margin-bottom: var(--space-5);
max-width: 40rem;
margin: 0 auto var(--space-4);
}
.category {
margin-bottom: var(--space-5);
scroll-margin-top: 5rem;
.restore-row {
max-width: 36rem;
margin: var(--space-2) auto 0;
}
h2 {
.back {
display: inline-block;
margin-bottom: var(--space-3);
border: 0;
background: none;
cursor: pointer;
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(16rem, 1fr));
gap: var(--space-3);
}
.card {
display: flex;
flex-direction: column;
gap: var(--space-2);
padding: var(--space-3) var(--space-3) var(--space-4);
color: inherit;
transition:
border-color var(--transition-fast),
box-shadow var(--transition-fast),
transform var(--transition-fast);
}
.card:hover {
text-decoration: none;
border-color: var(--accent);
box-shadow: var(--shadow-card);
transform: translateY(-1px);
}
.card-title {
font-weight: 600;
font-size: var(--text-m);
}
.card:hover .card-title {
.back:hover {
color: var(--accent);
}
.card-desc {
display: -webkit-box;
-webkit-line-clamp: 3;
line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
</style>
+79
View File
@@ -0,0 +1,79 @@
<script lang="ts">
import { CATEGORIES } from '$lib/categories';
import ToolCard from '$lib/components/search/ToolCard.svelte';
import { TOOLS } from '$lib/registry';
</script>
<svelte:head>
<title>Все инструменты — easy-png-tools</title>
<meta
name="description"
content="Полный каталог PNG-утилит: конвертация, прозрачность, цвет, геометрия, анализ и генерация изображений."
/>
</svelte:head>
<h1>Каталог инструментов</h1>
<p class="lead text-muted">
{TOOLS.length} утилит для работы с PNG. Все операции выполняются локально в браузере.
</p>
{#each CATEGORIES as category (category.id)}
{@const categoryTools = TOOLS.filter((tool) => tool.category === category.id)}
{#if categoryTools.length > 0}
<section id={category.id} class="category" aria-labelledby="{category.id}-heading">
<h2 id="{category.id}-heading" class="heading-section">{category.label}</h2>
<div class="grid">
{#each categoryTools as tool (tool.id)}
<div class="panel">
<ToolCard
toolId={tool.id}
title={tool.title}
description={tool.description}
href="/tools/{tool.id}"
/>
</div>
{/each}
</div>
</section>
{/if}
{/each}
<style>
h1 {
margin-bottom: var(--space-1);
}
.lead {
max-width: 48rem;
margin-bottom: var(--space-5);
}
.category {
margin-bottom: var(--space-5);
scroll-margin-top: 5rem;
}
h2 {
margin-bottom: var(--space-3);
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(16rem, 1fr));
gap: var(--space-3);
}
.grid > .panel {
padding: var(--space-2) var(--space-2) var(--space-3);
transition:
border-color var(--transition-fast),
box-shadow var(--transition-fast),
transform var(--transition-fast);
}
.grid > .panel:hover {
border-color: var(--accent);
box-shadow: var(--shadow-card);
transform: translateY(-1px);
}
</style>
+3
View File
@@ -3,6 +3,9 @@ import { sveltekit } from '@sveltejs/kit/vite';
import { defineConfig } from 'vite';
export default defineConfig({
ssr: {
noExternal: ['@lucide/svelte']
},
plugins: [
sveltekit({
compilerOptions: {
+2
View File
@@ -1,6 +1,8 @@
import { svelte } from '@sveltejs/vite-plugin-svelte';
import { defineConfig } from 'vitest/config';
export default defineConfig({
plugins: [svelte()],
test: {
include: ['src/**/*.test.ts'],
environment: 'node'