Compare commits
34
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7fa2c52bb8 | ||
|
|
17b0c68eb5 | ||
|
|
3b18d0c6f8 | ||
|
|
b48a8adfa7 | ||
|
|
96c37aabc5 | ||
|
|
7b799969c4 | ||
|
|
3701fc8045 | ||
|
|
7e391a420b | ||
|
|
aa5372b771 | ||
|
|
d0ee4ec77f | ||
|
|
5560435b55 | ||
|
|
dcc7c346c4 | ||
|
|
da92815b3a | ||
|
|
caf71ec8f0 | ||
|
|
f576701baa | ||
|
|
4c5b52a9ad | ||
|
|
7afd07e8c1 | ||
|
|
06c6716cf5 | ||
|
|
a9234d27de | ||
|
|
c7b56bbda7 | ||
|
|
15e2e010ec | ||
|
|
3620c5fc30 | ||
|
|
c90f887d7d | ||
|
|
cd158b188a | ||
|
|
da55328491 | ||
|
|
4b7086693c | ||
|
|
08038ede0d | ||
|
|
3ec11999d8 | ||
|
|
c0225552e4 | ||
|
|
719bda2e22 | ||
|
|
9ccc9f94ac | ||
|
|
4ec2b418fd | ||
|
|
85f595f14b | ||
|
|
d71f8648f5 |
@@ -0,0 +1 @@
|
||||
node_modules
|
||||
@@ -0,0 +1,109 @@
|
||||
# План: единая дизайн-система
|
||||
|
||||
> **СТАТУС: ВЫПОЛНЕН 2026-08-22.**
|
||||
|
||||
## 1. Цели и принципы
|
||||
|
||||
1. **Один источник правды** — все визуальные константы живут в токенах `web/src/app.css`; ни один компонент не пишет «магические» цвета/размеры напрямую.
|
||||
2. **Никакого дублирования** — повторяющийся визуальный рецепт оформляется один раз: либо токен, либо глобальный утилитарный класс, либо компонент в `ui/`.
|
||||
3. **Простые компоненты важнее умных** — если проще сделать отдельные `SliderField` и `TextField`, делаем два маленьких компонента, а не один конфигурируемый монстр.
|
||||
4. **Scoped-стили — только для раскладки** конкретного компонента (сетка, отступы между блоками). Внешний вид контролов и текстов — из дизайн-системы.
|
||||
|
||||
## 2. Аудит: что дублируется прямо сейчас
|
||||
|
||||
- **«Приглушённый текст»** (`--text-muted` + размер 0.85–0.95rem) — повторён в семи местах: `ToolPage` (.description, .hint), главная (.lead, .card-desc), лейаут (.nav-link, footer), `Preview` (.dims), `ParamForm` (label), `InfoPanel` (dt).
|
||||
- **«Заголовок секции капсом»** (uppercase, letter-spacing, muted) — точная копипаста в `ToolPage` и на главной.
|
||||
- **Базовые инпуты** (padding, border, radius) — живут только внутри `ParamForm`; любые новые типы контролов (слайдеры, пипетка цвета) пришлось бы копировать вручную.
|
||||
- **Блок «label + отступы»** — дублировался бы в каждом `*Field`; решается обёрткой `Field.svelte`.
|
||||
- **«Панель»** (surface + border + radius) — три ручные реализации одного паттерна: `DropZone`, карточки главной, строки `InfoPanel`.
|
||||
- **Кнопки** — глобальные классы `.primary`/`.secondary` без компонента; состояния «занят/недоступен» каждый описывает заново.
|
||||
- **Ошибка-баннер** — только в `ToolPage`, а понадобится минимум ещё раз в workspace.
|
||||
|
||||
## 3. Токены (расширение `app.css`)
|
||||
|
||||
Существующие остаются; добавляются недостающие группы:
|
||||
|
||||
```css
|
||||
:root {
|
||||
/* типографика */
|
||||
--text-xs: 0.8rem;
|
||||
--text-s: 0.85rem;
|
||||
--text-m: 0.95rem;
|
||||
--text-l: 1.1rem;
|
||||
--text-xl: 1.25rem;
|
||||
--text-xxl: 1.75rem;
|
||||
--leading-tight: 1.25;
|
||||
|
||||
/* тени */
|
||||
--shadow-card: 0 4px 14px rgb(37 99 235 / 12%);
|
||||
|
||||
/* фокус */
|
||||
--focus-ring: 2px solid var(--accent);
|
||||
|
||||
/* контрольные размеры контролов */
|
||||
--control-height: 2.4rem;
|
||||
--control-max-width: 16rem;
|
||||
}
|
||||
```
|
||||
|
||||
Правило именования: семантика, не значение (`--text-muted`, а не `--gray-500`).
|
||||
|
||||
## 4. Глобальные утилиты (`app.css`)
|
||||
|
||||
Только то, что не требует JS и не является интерактивным:
|
||||
|
||||
- `.text-muted` — приглушённый текст (размер наследуется);
|
||||
- `.text-caption` — мелкий текст `--text-s`;
|
||||
- `.heading-section` — заголовок секции капсом (вместо двух копипаст);
|
||||
- `.panel` — поверхность с бордером/радиусом (DropZone, карточки, InfoPanel-строки);
|
||||
- `.error-banner` — баннер ошибки;
|
||||
- `.visually-hidden` — скрытый, но читаемый скринридерами.
|
||||
|
||||
Элементные стили из текущего `app.css` (голые `input, select { ... }`) убрать — внешний вид контролов переезжает в компоненты `ui/`.
|
||||
|
||||
## 5. Состав UI-компонентов (`web/src/lib/components/ui/`)
|
||||
|
||||
Все — тонкие обёртки над нативными элементами, без состояний кроме своих:
|
||||
|
||||
| Компонент | Пропы | Заменяет |
|
||||
| ---------------------- | -------------------------------------------------------------------------------- | ---------------------------------------------------------------------- |
|
||||
| `Field.svelte` | `id`, `label`, `hint?`, слот контрола | label + раскладка поля; единая точка для hint'ов и ошибок валидации |
|
||||
| `Button.svelte` | `variant: 'primary' \| 'secondary'`, `disabled`, `busy`, слот текста | глобальные `.primary`/`.secondary`, локальный busy в `DownloadButton` |
|
||||
| `TextField.svelte` | `id`, `label`, `type: 'number' \| 'text'`, `min/max/step`, `value = $bindable()` | number/text ветки `ParamForm` |
|
||||
| `SliderField.svelte` | `id`, `label`, `min/max/step`, `value = $bindable()`, показ значения рядом | ничего — готовая база для будущих слайдеров (яркость, качество, порог) |
|
||||
| `SelectField.svelte` | `id`, `label`, `options`, `value = $bindable()` | select-ветка `ParamForm` |
|
||||
| `CheckboxField.svelte` | `id`, `label`, `checked = $bindable()` | чекбокс `ParamForm` |
|
||||
| `ColorField.svelte` | `id`, `label`, `value = $bindable()` | color-ветка `ParamForm`; точка будущего расширения пипеткой |
|
||||
| `EmptyState.svelte` | `title`, `hint`, слот действия | «Обработка…», будущие пустые панели исходника/результата |
|
||||
|
||||
`Field` — чисто раскладочная обёртка (`<div class="field"><label for>…</label><slot /></div>`), ничего не знает о типе контрола. На ней построены `TextField`, `SliderField`, `SelectField`, `ColorField`. Исключение — `CheckboxField`: у чекбокса label оборачивает контрол (кликабельная строка), поэтому у него своя раскладка.
|
||||
|
||||
## 6. Миграция существующего
|
||||
|
||||
1. `ParamForm` — переписывается на `Field` + `*Field`-компоненты; собственная стилизация контролов и label удаляется.
|
||||
2. `DownloadButton` и кнопки `ToolPage` — на `Button`.
|
||||
3. `DropZone`, карточки главной, строки `InfoPanel` — на `.panel` + свои раскладочные стили.
|
||||
4. `ToolPage`, главная, лейаут — приглушённые тексты на `.text-*`, заголовки секций на `.heading-section`, баннер на `.error-banner`.
|
||||
5. Из `app.css` удалить глобальные классы кнопок и голые стили инпутов после перехода.
|
||||
|
||||
## 7. Порядок работ
|
||||
|
||||
| Этап | Содержимое | Проверка |
|
||||
| ------------ | ----------------------------------------------------- | -------------------------------------------------------------------- |
|
||||
| A. Фундамент | Токены + утилиты в `app.css` | `pnpm check` |
|
||||
| B. Примитивы | Восемь компонентов в `components/ui/` | `pnpm check`; каждый используется хотя бы раз после этапа C |
|
||||
| C. Миграция | Переделка пяти файлов из §6 | Визуально: страницы выглядят как раньше; `test/check/build` зелёные |
|
||||
| D. Чистка | Удаление устаревших классов и дублей из scoped-стилей | grep: в `components/` нет hex-цветов и `var(--space…)` вне раскладки |
|
||||
|
||||
## 8. Критерии готовности
|
||||
|
||||
- Ни один компонент не задаёт цвет/радиус/шрифт мимо токенов или классов системы.
|
||||
- Все интерактивные контролы на страницах — экземпляры `ui/*`.
|
||||
- `grep -E '#[0-9a-f]{3,6}' web/src/lib/components` не находит ничего, кроме токенов в `app.css`.
|
||||
- Визуальной регрессии нет (ручная сверка всех страниц до/после), тесты и билд зелёные.
|
||||
|
||||
## 9. Что сознательно не делаем
|
||||
|
||||
- Никаких CSS-фреймворков и препроцессоров — обычный CSS, как решено на старте.
|
||||
- Никаких тем/тёмного режима — но токены сразу семантические, чтобы тема добавилась заменой значений `:root`.
|
||||
- Никаких сложных составных компонентов (модалки, таблицы) — они появятся, когда потребуется реальная задача.
|
||||
@@ -0,0 +1,166 @@
|
||||
# План начальной реализации: минимальный рабочий MVP
|
||||
|
||||
> **СТАТУС: ВЫПОЛНЕН 2026-08-22.**
|
||||
>
|
||||
> **Что сделано по плану:**
|
||||
>
|
||||
> - Шаг 0 — каркас: SvelteKit + adapter-static, design-токены на CSS, лейаут с навигацией по категориям ✔
|
||||
> - Шаг 1 — ядро: `PixelImage`, декодирование файлов, кодирование PNG/JPEG/WebP, скачивание ✔
|
||||
> - Шаг 2 — операции ядра: color / geometry / alpha / analyze, чистые функции без DOM ✔
|
||||
> - Шаг 3 — реестр из 11 инструментов + санитизация параметров ✔
|
||||
> - Шаг 4 — универсальные UI-компоненты (DropZone, Preview, ParamForm, DownloadButton, InfoPanel, ToolPage) ✔
|
||||
> - Шаг 5 — маршруты: `/tools/[id]` с prerender всех страниц, главная-каталог по категориям ✔
|
||||
> - Шаги 6–7 — обработка ошибок, состояния, полировка; верификация ✔
|
||||
>
|
||||
> **Checkpoint пройден:** все 11 инструментов вручную проверены в браузере (загрузка → применение → скачивание), включая найденный и исправленный баг resize с сохранением пропорций. Тесты 75/75, svelte-check 0 ошибок, статический экспорт собирается.
|
||||
>
|
||||
> **Попутные улучшения сверх плана:** корневой скрипт `pnpm dev`; `sanitizeParams` против пустых/невалидных значений полей; семантика «вписать в размеры» для resize.
|
||||
|
||||
## 0. Зафиксированные решения
|
||||
|
||||
- **Объём MVP:** только одиночные инструменты «загрузил → настроил → скачал». Пайплайн-workspace (`/workspace`) вне скоупа этого плана.
|
||||
- **Набор инструментов первого среза:** ~11 базовых EASY (см. §3). Достаточно, чтобы проверить насквозь ядро → реестр → универсальную страницу.
|
||||
- **Стили:** обычный CSS без фреймворка — design-токены на CSS custom properties, ноль зависимостей.
|
||||
- **Стек:** SvelteKit + `@sveltejs/adapter-static` + TypeScript, пакетный менеджер pnpm (уже зафиксирован в корневом `package.json`). Весь код сайта — в `web/`.
|
||||
- **Ядро без DOM:** все пиксельные операции — чистые функции над `PixelImage`; DOM/canvas допускается только в модуле IO и компонентах превью.
|
||||
|
||||
---
|
||||
|
||||
## 1. Шаг 0 — Каркас проекта
|
||||
|
||||
1. Создать SvelteKit-приложение в `web/`: `npx sv create web` (шаблон minimal, TypeScript, без лишних аддонов).
|
||||
2. `pnpm install` внутри `web/`.
|
||||
3. `svelte.config.js`: заменить адаптер на `@sveltejs/adapter-static`.
|
||||
4. `src/routes/+layout.ts`: `export const prerender = true;` — весь сайт prerenderится в статику.
|
||||
5. `src/app.css`: дизайн-токены (палитра, типографика, отступы, радиусы через CSS custom properties) + минимальный сброс стилей.
|
||||
6. `src/routes/+layout.svelte`: шапка с названием сайта и навигацией по категориям инструментов (категории — константа из реестра), футер.
|
||||
|
||||
**Проверка:** `pnpm dev` открывает пустой каркас; `pnpm build && pnpm preview` отдаёт статический экспорт.
|
||||
|
||||
---
|
||||
|
||||
## 2. Шаг 1 — Ядро: типы и ввод-вывод
|
||||
|
||||
Файлы в `web/src/lib/core/`:
|
||||
|
||||
- `types.ts`
|
||||
|
||||
```ts
|
||||
export type PixelImage = { width: number; height: number; data: Uint8ClampedArray }; // RGBA, как ImageData
|
||||
```
|
||||
|
||||
- `io.ts` — единственное место ядра, где разрешён canvas/DOM:
|
||||
- `decodeFile(file: File): Promise<PixelImage>` — `createImageBitmap(file)` → отрисовка на canvas → `getImageData`;
|
||||
- `encode(img: PixelImage, mime: 'image/png' | 'image/jpeg' | 'image/webp', quality?: number): Promise<Blob>` — `putImageData` → `canvas.toBlob`;
|
||||
- `downloadBlob(blob: Blob, filename: string): void`.
|
||||
|
||||
**Проверка:** юнит-смоук — декодировать фикстурный PNG, закодировать обратно, размеры совпадают.
|
||||
|
||||
---
|
||||
|
||||
## 3. Шаг 2 — Операции ядра (чистые функции)
|
||||
|
||||
Файлы в `web/src/lib/core/`, каждая функция: `(img, params) => new PixelImage`, без мутации входа:
|
||||
|
||||
- `color.ts`: `grayscale(img)` (luma), `invert(img)`, `brightnessContrast(img, brightness −100..100, contrast −100..100)`.
|
||||
- `geometry.ts`: `flip(img, axis)`, `rotate90(img, turns)`, `crop(img, x, y, w, h)` — индексная математика; `resize(img, w, h)` — билинейная интерполяция на TS (не `drawImage`, чтобы ядро осталось DOM-free и воспроизводимым бит-в-бит).
|
||||
- `alpha.ts`: `removeColorToAlpha(img, hex, tolerancePercent)` — цветовое расстояние → альфа 0.
|
||||
- `analyze.ts`: `imageInfo(img)` → `{ width, height, hasAlpha, colorCount }`.
|
||||
|
||||
**Проверка:** vitest на маленьких матрицах пикселей (2×2, 3×3): flip, rotate, crop, grayscale — ожидаемые значения руками.
|
||||
|
||||
---
|
||||
|
||||
## 4. Шаг 3 — Реестр инструментов
|
||||
|
||||
`web/src/lib/registry.ts` — источник истины для страниц, форм и навигации:
|
||||
|
||||
```ts
|
||||
export type ParamDef =
|
||||
| { id: string; label: string; type: "number"; min?: number; max?: number; step?: number; default: number }
|
||||
| { id: string; label: string; type: "select"; options: { value: string; label: string }[]; default: string }
|
||||
| { id: string; label: string; type: "checkbox"; default: boolean }
|
||||
| { id: string; label: string; type: "color"; default: string };
|
||||
|
||||
export type ToolEntry = {
|
||||
id: string; // slug маршрута
|
||||
title: string;
|
||||
description: string;
|
||||
category: CategoryId;
|
||||
params: ParamDef[];
|
||||
run: (img: PixelImage, params: Record<string, unknown>) => Promise<PixelImage> | PixelImage;
|
||||
};
|
||||
```
|
||||
|
||||
Категории (константа там же): `convert`, `alpha`, `color`, `geometry`, `analyze`.
|
||||
|
||||
### Состав первого среза — 11 инструментов
|
||||
|
||||
| # | id | Категория | Параметры |
|
||||
| --- | -------------------------------- | --------- | ----------------------------------------------------- |
|
||||
| 1 | `resize-png` | geometry | width, height, checkbox «сохранять пропорции» |
|
||||
| 2 | `crop-png` | geometry | x, y, width, height |
|
||||
| 3 | `rotate-png` | geometry | select: 90 / 180 / 270 |
|
||||
| 4 | `flip-png` | geometry | select: horizontal / vertical |
|
||||
| 5 | `grayscale-png` | color | — |
|
||||
| 6 | `invert-colors-png` | color | — |
|
||||
| 7 | `adjust-brightness-contrast-png` | color | brightness −100..100, contrast −100..100 |
|
||||
| 8 | `convert-png-to-jpg` | convert | background color (подложка под альфу), quality 0..100 |
|
||||
| 9 | `convert-png-to-webp` | convert | quality 0..100 |
|
||||
| 10 | `remove-color-from-png` | alpha | hex color, tolerance % |
|
||||
| 11 | `png-info` | analyze | — особый случай: не картинка, а текстовая сводка |
|
||||
|
||||
Для `png-info` в записи реестра добавляется флаг `resultType: 'info'`; остальные — `'image'`.
|
||||
|
||||
**Проверка:** импорт реестра в тесте — все id уникальны, у каждого `params` имеют дефолты, `run` определён.
|
||||
|
||||
---
|
||||
|
||||
## 5. Шаг 4 — Универсальные UI-компоненты
|
||||
|
||||
`web/src/lib/components/`:
|
||||
|
||||
- `DropZone.svelte` — drag&drop + клик; эмитит `File`; проверка MIME (PNG/JPEG/WebP/GIF/BMP).
|
||||
- `Preview.svelte` — рендер `PixelImage` на canvas с fit-масштабированием; шахматный фон под прозрачность.
|
||||
- `ParamForm.svelte` — генерирует контролы из `ParamDef[]`, двусторонний `bind:values`.
|
||||
- `DownloadButton.svelte` — encode выбранного формата + `downloadBlob`, имя `<tool-id>.<ext>`.
|
||||
- `InfoPanel.svelte` — текстовый вывод для `resultType: 'info'`.
|
||||
- `ToolPage.svelte` — оркестратор страницы инструмента; состояния `idle → loaded → processing → done | error`; раскладка: слева DropZone, справа превью + форма параметров + кнопки «Применить» / «Скачать».
|
||||
|
||||
**Правило:** новый инструмент = новая запись в реестре (+ функция операции). Компоненты и маршрут не трогаем.
|
||||
|
||||
---
|
||||
|
||||
## 6. Шаг 5 — Маршруты
|
||||
|
||||
- `/` (`src/routes/+page.svelte`) — сетка карточек инструментов, сгруппированных по категориям; данные из реестра.
|
||||
- `/tools/[id]` — универсальная страница:
|
||||
- `+page.ts`: `entries()` возвращает все `id` из реестра (обязательное условие prerender при adapter-static);
|
||||
- `load()` возвращает запись реестра по `id`, неизвестный id → 404;
|
||||
- `+page.svelte` — тонкая обёртка над `ToolPage`.
|
||||
|
||||
**Проверка:** `pnpm build` — в `build/tools/` лежат все 11 страниц; главная содержит ссылки на каждую.
|
||||
|
||||
---
|
||||
|
||||
## 7. Шаг 6 — Ошибки и минимальная полировка
|
||||
|
||||
- Обработка ошибок: неподдерживаемый формат, ошибка декода, некорректные параметры crop (выход за границы — клампы или понятная ошибка).
|
||||
- Состояния: «перетащите файл», спиннер во время обработки, disabled-кнопки до загрузки.
|
||||
- Тексты описаний для всех 11 инструментов (title/description уже в реестре).
|
||||
|
||||
---
|
||||
|
||||
## 8. Шаг 7 — Верификация и checkpoint
|
||||
|
||||
Команды:
|
||||
|
||||
```bash
|
||||
cd web && pnpm dev # разработка
|
||||
cd web && pnpm build # статический экспорт должен проходить без ошибок
|
||||
cd web && pnpm test # vitest: ядро (геометрия/цвет) + целостность реестра
|
||||
```
|
||||
|
||||
Ручной smoke-тест каждого из 11 инструментов: загрузка PNG → применить → скачать → открыть результат.
|
||||
|
||||
**Checkpoint MVP:** любой из 11 инструментов доступен по прямому URL с главной страницы; PNG загружается drag&drop; параметры применяются; результат скачивается; всё работает офлайн со статического хостинга; ни одной серверной зависимости.
|
||||
@@ -0,0 +1,69 @@
|
||||
# План: range-слайдеры и пипетка цвета
|
||||
|
||||
> **СТАТУС: ВЫПОЛНЕН 2026-08-23.**
|
||||
|
||||
## 1. Целевое поведение
|
||||
|
||||
1. «Процентные» параметры (яркость, контраст, качество JPEG/WebP, порог похожести) — слайдеры с числовым значением рядом; точные координаты и размеры (resize/crop) остаются полями ввода.
|
||||
2. У цветовых параметров появляется кнопка-переключатель «Пипетка»: активна → клик по превью исходника снимает цвет пикселя и подставляет его в поле цвета; после выбора режим гаснет.
|
||||
3. В режиме пипетки возле курсора видна лупа: увеличенный блок пикселей вокруг точки с перекрестием и подписью hex текущего пикселя; клик выбирает центр перекрестия.
|
||||
4. Активная пипетка видна на превью перекрестием курсора; повторный клик по кнопке отменяет режим без выбора.
|
||||
5. Всё работает поверх текущего процесса: авто-применение с задержкой, токен запусков, маски — без изменений.
|
||||
|
||||
## 2. Слайдеры
|
||||
|
||||
- В реестре новый тип параметра `'slider'` с обязательными `min`/`max`; валидируется как число (дефолт в диапазоне, минимум меньше максимума).
|
||||
- Форма маппит его на готовый `SliderField` из `ui/`; санитизация параметров обрабатывает slider так же, как number.
|
||||
- Переводятся: яркость и контраст, качество JPEG, качество WebP, порог похожести. Остальные инструменты — без изменений.
|
||||
|
||||
## 3. Пипетка и лупа
|
||||
|
||||
- В ядро добавляется чистая функция `rgbToHex(r, g, b)` → строка вида `#rrggbb`, с юнит-тестами; чтение пикселя — из уже отрисованного canvas превью исходника через `getImageData(x, y, 1, 1)`.
|
||||
- `Preview.svelte` получает опциональный режим пипетки: пропсы «активна» и колбэк «цвет выбран»; курсор перекрестие, клик вычисляет координату пикселя с учётом разницы CSS-размера canvas и его внутренней ширины.
|
||||
- **Лупа** — компонент `PipetteLoupe.svelte`: канвас около 110×110 px у курсора (позиция fixed, `pointer-events: none`); рисует блок 15×15 пикселей из исходника с увеличением ×8 без сглаживания, перекрестие в центре, подпись hex текущего пикселя. Координаты блока клампятся у краёв изображения; лупа скрыта вне превью и вне режима пипетки. Тач-устройства: прямой тап выбирает пиксель без лупы.
|
||||
- Проводка: кнопка у каждого цветового поля в карточке параметров → состояние «активное цветовое поле» живёт в оркестраторе → выбор цвета записывает hex в это поле и выключает режим.
|
||||
- Пипетка работает только по левой панели (исходнику): удаляемый цвет и подложка берутся с оригинала.
|
||||
|
||||
## 4. Этапы и проверки
|
||||
|
||||
- **A. Слайдеры** — тип в реестре, перевод четырёх инструментов, тесты реестра и санитизации, тест `rgbToHex`. Проверка: `pnpm test/check`, визуально слайдеры.
|
||||
- **B. Пипетка** — режим в Preview, проводка через карточки до полей цвета. Проверка: смоук §6 пп. 5–7.
|
||||
- **B2. Лупа** — компонент лупы поверх режима пипетки. Проверка: смоук §6 пп. 8–10.
|
||||
- **C. Итог** — полный прогон `test/check/build`.
|
||||
|
||||
## 5. Изменения по файлам
|
||||
|
||||
- `registry.ts` — тип `'slider'` в `ParamDef`, перевод четырёх записей, ветка в `sanitizeParams`.
|
||||
- `ParamForm.svelte` — ветка slider → `SliderField`.
|
||||
- `core/color.ts` — `rgbToHex`.
|
||||
- `Preview.svelte` — режим пипетки (курсор, клик, колбэк).
|
||||
- `PipetteLoupe.svelte` — лупа у курсора в режиме пипетки.
|
||||
- `SourceCard.svelte` — проброс режима и колбэка наверх.
|
||||
- `ParamsCard.svelte` + `ColorField.svelte` — кнопка «Пипетка» у цветового поля.
|
||||
- `ToolPage.svelte` — состояние активного поля, обработчик выбора цвета.
|
||||
|
||||
## 6. Смоук-чеклист
|
||||
|
||||
1. Яркость/контраст — слайдеры со значением справа, результат обновляется сам при перетаскивании.
|
||||
2. Качество JPEG/WebP — слайдер от 1 до 100.
|
||||
3. Порог похожести — слайдер от 0 до 100.
|
||||
4. Resize и crop остались числовыми полями.
|
||||
5. Удаление цвета: «Пипетка» → клик по красной области исходника → поле цвета получил hex этого пикселя, режим погас, результат пересчитался.
|
||||
6. Повторный клик по кнопке или выбор без подтверждения — режим снят, цвет не изменился.
|
||||
7. Подложка JPEG: пипетка проставляет цвет подложки тем же способом.
|
||||
8. В режиме пипетки лупа следует за курсором, перекрестие показывает пиксель под ним, подпись hex совпадает с выбранным при клике.
|
||||
9. У края картинки блок лупи корректно клампится — за границей тянется чёрный/прозрачный фон, выбор по-прежнему точен.
|
||||
10. Курсор ушёл с превью или режим выключен — лупа скрыта; клики по кнопкам под превью не перехватываются лупой.
|
||||
11. Слайдеры и пипетка дружат с быстрыми правками: одна финальная версия результата, чип «Пересчёт…» на месте.
|
||||
|
||||
## 7. Критерии готовности
|
||||
|
||||
- Все процентные параметры — слайдеры; числовые поля остались только там, где нужен точный ввод.
|
||||
- Цвет любого color-параметра можно взять кликом с исходника.
|
||||
- `test/check/build` зелёные, чеклист §6 пройден.
|
||||
|
||||
## 8. Что сознательно не делаем
|
||||
|
||||
- Пипетка и лупа на превью результата и маски — только исходник.
|
||||
- Кастомная стилизация слайдеров за пределами дизайн-системы.
|
||||
- Лупа для тач-устройств — там прямой тап без лупы.
|
||||
@@ -0,0 +1,66 @@
|
||||
# План: декомпозиция страницы инструмента, реактивные параметры, предпросмотр маски
|
||||
|
||||
> **СТАТУС: ВЫПОЛНЕН 2026-08-23.**
|
||||
|
||||
## 1. Зачем
|
||||
|
||||
- Страница инструмента (`ToolPage.svelte`) разрослась: состояние процесса, загрузка файлов, вставка из буфера и разметка трёх зон живут в одном файле.
|
||||
- Назревают два требования к процессу: параметры применяются автоматически без кнопки; у части утилит на экране показывается одно — визуализация фильтра/маски, а скачивается (и в будущем передаётся следующему инструменту) другое — результат.
|
||||
|
||||
## 2. Контракт «результат и маска» в реестре
|
||||
|
||||
- `run(img, params)` — как сейчас: возвращает итоговое изображение. Его скачивают и будут передавать дальше по цепочке.
|
||||
- Запись реестра получает опциональное поле `preview?(img, params)` — возвращает картинку для показа на экране (маску/фильтр).
|
||||
- Если у записи есть `preview`, в карточке результата появляется переключатель «Показать маску». Включён — на превью маска, выключен (по умолчанию) — результат. Скачивание всегда отдаёт результат `run`, независимо от положения переключателя.
|
||||
- Первая реализация — удаление цвета: в ядро добавляется чистая функция `colorMask(img, hex, tolerance)` — чёрно-белая маска, где удаляемые пиксели белые; с юнит-тестами; `preview` записи вызывает её.
|
||||
|
||||
## 3. Декомпозиция страницы
|
||||
|
||||
Новая папка `components/tool/`; карточки — чисто отображательные, состояние живёт только в оркестраторе:
|
||||
|
||||
- **`SourceCard.svelte`** — левая половина сцены: пусто → зона перетаскивания; загружено → превью исходника под оверлеем замены с кнопкой «Заменить изображение».
|
||||
- **`ResultCard.svelte`** — правая половина: пустые состояния, превью результата или сводка, чип «Пересчёт…», кнопка скачивания, при наличии `preview` — переключатель маски.
|
||||
- **`ParamsCard.svelte`** — форма параметров и подпись «Изменения применяются автоматически»; для инструментов без параметров не выводится.
|
||||
- **`ToolPage.svelte`** остаётся оркестратором: состояние (исходник, результат, сводка, статус, ошибка, значения), декодирование файлов, вставка из буфера, сброс, токен запусков и эффект с задержкой. Ожидаемо худеет примерно вдвое.
|
||||
|
||||
Карточки переиспользуются будущим многошаговым режимом.
|
||||
|
||||
## 4. Реактивные параметры (в этом же проходе)
|
||||
|
||||
- Кнопки «Применить» больше нет ни у одного инструмента.
|
||||
- Изменение параметра запускает вычисление через 300 мс после последнего изменения; каждое новое изменение перезапускает таймер.
|
||||
- Токен запусков: если во время вычисления параметры поменялись, устаревший ответ отбрасывается.
|
||||
- Загрузка и замена файла считают сразу и подавляют ровно один авто-запуск эффекта.
|
||||
- Ошибка пересчёта — баннер сверху, прежний результат не сбрасывается.
|
||||
|
||||
## 5. Этапы и проверки
|
||||
|
||||
- **A. Декомпозиция** — перенос разметки в три карточки один к одному, поведение не меняется. Проверка: `check/build`, визуально идентично.
|
||||
- **B. Реактивность** — токен, задержка, чип пересчёта, минус кнопка. Проверка: смоук §6 пп. 1–6.
|
||||
- **C. Маска** — типы реестра, `colorMask` с тестами, переключатель в карточке результата, пример на удалении цвета. Проверка: тесты + смоук §6 пп. 7–9.
|
||||
- **D. Итог** — полный прогон `test/check/build`.
|
||||
|
||||
## 6. Смоук-чеклист
|
||||
|
||||
1. Загрузка файла → результат появляется сразу.
|
||||
2. Изменить параметр → около 0.3 с и результат обновился сам.
|
||||
3. Быстро изменить яркость несколько раз → одна финальная версия, промежуточные не мелькают.
|
||||
4. Во время пересчёта чип виден, предыдущий результат не исчезает.
|
||||
5. Инструмент без параметров и информационная страница ведут себя как раньше.
|
||||
6. Некорректные параметры (обрезка вне картинки) → баннер ошибки, прежний результат цел.
|
||||
7. Переключатель «Показать маску» у удаления цвета: включён — белым по чёрному видно, что удалится; выключен — обычный полупрозрачный результат.
|
||||
8. С включённой маской скачивание всё равно сохраняет прозрачный результат, а не маску.
|
||||
9. Замена исходника возвращает переключатель маски в выключенное состояние.
|
||||
|
||||
## 7. Критерии готовности
|
||||
|
||||
- `ToolPage` содержит только оркестрацию состояния и эффектов; вся разметка панелей — в `components/tool/*`.
|
||||
- Кнопки «Применить» нет ни на одной странице.
|
||||
- Скачивание всегда соответствует `run`; экран может показывать `preview`.
|
||||
- `test/check/build` зелёные, смоук §6 пройден.
|
||||
|
||||
## 8. Что сознательно не делаем
|
||||
|
||||
- Вынос вычислений в веб-воркер; настраиваемая задержка — фиксированные 300 мс.
|
||||
- Редактор масок (кисть, слои, история) — только автогенерация из параметров инструмента.
|
||||
- Передача результата между инструментами — придёт вместе с многошаговым режимом позже.
|
||||
@@ -0,0 +1,74 @@
|
||||
# План: раскладка «исходник слева — результат справа» + вставка изображения
|
||||
|
||||
> **СТАТУС: ВЫПОЛНЕН 2026-08-22.**
|
||||
>
|
||||
> **Что сделано по этапам:**
|
||||
>
|
||||
> - A — общая MIME-валидация `isSupportedImage` + `unsupportedImageMessage` в `core/io.ts`; `DropZone` переведён на них ✔
|
||||
> - B — постоянная двухпанельная раскладка: одна карточка-«сцена» с равными половинами «Исходник | Результат», кнопки внутри своих половин, параметры — отдельной карточкой ниже; контент сайта растянут на всю ширину окна ✔
|
||||
> - C — Ctrl+V: `<svelte:window onpaste>`, поиск image-item в буфере, общая валидация; текстовые вставки не затрагиваются ✔
|
||||
> - D — drag-замена: компонент `DropOverlay` с вуалью-подсказкой и счётчиком `dragenter/dragleave` против мерцания; тот же счётчик в `DropZone` ✔
|
||||
> - E — верификация: тесты 85/85, svelte-check 0 ошибок, статический билд с 11 страницами; ручной смоук-чеклист §5 пройден ✔
|
||||
>
|
||||
> **Отступления от исходного плана в ходе реализации:** вместо пропа `overlay` у `DropZone` сделан отдельный компонент-обёртка `DropOverlay` (drag-события ловит контент под курсором); раскладка доработана по ревью — одна карточка равных половин вместо двух независимых колонок, сайт без ограничения ширины.
|
||||
|
||||
## 1. Целевое поведение
|
||||
|
||||
1. **Постоянная двухпанельная раскладка** страницы инструмента — отдельного состояния «страница загрузки» больше нет:
|
||||
- **Слева — исходник**, панель есть всегда. Не загружено → пустое дроп-поле (drag&drop или клик). Загружено → превью исходника.
|
||||
- **Справа — результат**: до загрузки — `EmptyState` «Результат появится здесь»; во время обработки — `EmptyState` «Обработка…»; после — превью результата (для info-инструмента — `InfoPanel`).
|
||||
2. **Ctrl+V** — картинка из буфера обмена вставляется как исходник с любого места страницы.
|
||||
3. **Замена перетаскиванием** — новый файл, брошенный на левую панель с загруженной картинкой, заменяет исходник (с оверлеем-подсказкой во время перетаскивания).
|
||||
4. **Кнопка «Заменить»** под превью исходника (переносится туда текущая «Загрузить другое изображение»).
|
||||
|
||||
На мобильной ширине панели складываются в столбец: исходник сверху, результат снизу.
|
||||
|
||||
## 2. Изменения по файлам
|
||||
|
||||
Новые компоненты не вводим — `EmptyState`, `Preview`, `DropZone` покрывают всё. Меняем три файла:
|
||||
|
||||
- **`core/io.ts`** — экспорт `isSupportedImage(file): boolean`: общая MIME-проверка на базе `ACCEPTED_IMAGE_TYPES`.
|
||||
- **`DropZone.svelte`** — переходит на `isSupportedImage`; новый опциональный проп `overlay` — режим «невидимая зона приёма поверх контента» для замены перетаскиванием.
|
||||
- **`ToolPage.svelte`** — постоянная сетка из двух панелей; `<svelte:window onpaste>`; drag-оверлей левой панели; кнопки замены/сброса переезжают в левую панель; правая панель — три состояния через `EmptyState`.
|
||||
|
||||
## 3. Этапы и проверки
|
||||
|
||||
- **A. Общая валидация** — `isSupportedImage` в `io.ts`, `DropZone` переведён на неё. Проверка: `pnpm check`.
|
||||
- **B. Раскладка** — две постоянные колонки в ToolPage; правая панель через EmptyState; кнопки уходят в левую панель; обёртка `{#if !source}` на всю страницу убирается. Проверка: визуальный осмотр всех инструментов, `check/build`.
|
||||
- **C. Ctrl+V** — `onpaste` на окне → поиск image-item в `clipboardData` → тот же `handleFile`; не-изображения игнорируются молча. Проверка: ручной смоук со скриншотом из буфера.
|
||||
- **D. Drag-замена** — оверлей DropZone(overlay) при dragover на левой панели; drop → `handleFile` нового файла. Проверка: ручной смоук «бросить файл на загруженный исходник».
|
||||
- **E. Итог** — полный ручной смоук-чеклист (§5). Проверка: `test/check/build` зелёные.
|
||||
|
||||
## 4. Нюансы и решения
|
||||
|
||||
- **Мерцание dragleave** — классическая проблема оверлеев: используем счётчик `dragenter/dragleave`, оверлей скрывается только когда счётчик обнулился и произошёл `drop`/`dragend`.
|
||||
- **Paste при фокусе в поле параметров** — обрабатываем всегда: если в буфере именно изображение, пользователь очевидно хочет заменить исходник; текстовые вставки обработчик не трогает.
|
||||
- **Форматы буфера** — скриншоты приходят как PNG, часть приложений отдаёт JPEG/WebP; все проходят общую `isSupportedImage`. GIF/BMP из буфера редки, но тоже допустимы.
|
||||
- **Info-инструменты** — ведут себя так же: слева исходник, справа после загрузки появляется `InfoPanel`.
|
||||
- **Состояние ошибки** — баннер остаётся сверху страницы; неудачная замена не сбрасывает старый исходник.
|
||||
|
||||
## 5. Чеклист ручного смоука
|
||||
|
||||
1. Пустые обе панели → drag&drop файла слева → появился исходник и результат.
|
||||
2. Клик по пустой левой панели → выбор файла.
|
||||
3. Ctrl+V со скриншотом → исходник заменился, результат пересчитался.
|
||||
4. Drag файла на загруженную левую панель → оверлей виден, после drop — замена.
|
||||
5. Отмена перетаскивания (Esc / мимо) → оверлей скрылся, исходник не изменился.
|
||||
6. Бросок неподдерживаемого файла (например .txt) → баннер ошибки, старый исходник цел.
|
||||
7. Кнопка «Заменить» открывает выбор файла.
|
||||
8. png-info: работает та же схема, справа сводка.
|
||||
9. Мобильная ширина: панели в столбец, порядок исходник → результат.
|
||||
10. Все 11 инструментов отвечают на замену исходника корректным пересчётом.
|
||||
|
||||
## 6. Критерии готовности
|
||||
|
||||
- На странице инструмента нет состояния «только дропзона на всю страницу» — раскладка двухпанельная всегда.
|
||||
- Загрузка работает тремя способами: клик, drag&drop (включая замену), Ctrl+V.
|
||||
- Ошибочные файлы и отмена перетаскивания не портят текущий исходник.
|
||||
- `test/check/build` зелёные; чеклист §5 пройден.
|
||||
|
||||
## 7. Что сознательно не делаем (граница скоупа этого плана)
|
||||
|
||||
- Множественная загрузка / пакетная обработка — вне скоупа этого плана.
|
||||
- Камера/скриншот-инструменты захвата — только буфер обмена.
|
||||
- Прогресс-бары декодирования — файлы читаются быстро, достаточно индикации «Обработка…».
|
||||
@@ -0,0 +1 @@
|
||||
# Backlog
|
||||
@@ -0,0 +1,68 @@
|
||||
# План: вторая волна EASY-инструментов
|
||||
|
||||
> Статус: план к выполнению.
|
||||
|
||||
## 1. Принципы отбора
|
||||
|
||||
- Максимум переиспользования: готовые контракты записей (`run`, `preview`, формат вывода, info-режим) и существующие операции ядра.
|
||||
- Новые механизмы ядра — минимальные, чистые, покрытые тестами до подключения инструментов.
|
||||
- Canvas/DOM-зависимые утилиты (SVG-вход, надписи и водяные знаки, эффекты через фильтры контекста) — вне этого пакета, отдельная волна.
|
||||
- Целевой объём волны: около 37 новых инструментов, каталог вырастает с 11 до ~48.
|
||||
|
||||
## 2. Новые механизмы
|
||||
|
||||
- **Генераторы без входного файла**: у записи появляется признак «источник не нужен»; страница такого инструмента не показывает левую панель и зону перетаскивания — только параметры и результат.
|
||||
- **Текстовый ввод и вывод**: часть утилит принимает вставленный текст вместо файла и выдаёт текстовый результат (панель с полем только для чтения, кнопки «копировать» и «скачать как текст»). Распространяется на base64, data-uri, hex.
|
||||
- **BMP-энкодер в ядре**: 24-битный, чистая функция из пикселей в байты, с тестами на маленьких матрицах (заголовок, порядок каналов BGR, паддинг строки).
|
||||
|
||||
## 3. Состав пакета
|
||||
|
||||
**Конвертация — 6**
|
||||
`jpg-to-png`, `webp-to-png`, `gif-to-png` (первый кадр), `bmp-to-png`, `ico-to-png` — всё через уже умеющий декодировать ввод, сохранение в PNG; плюс `png-to-bmp` на новом энкодере.
|
||||
|
||||
**Текстовые представления — 6**
|
||||
`png-to-base64`, `base64-to-png`, `png-to-data-uri`, `data-uri-to-png`, `png-to-hex`, `hex-to-png`.
|
||||
|
||||
**Цвет — 9**
|
||||
`change-opacity` (множитель прозрачности), `sepia`, `change-hue` (сдвиг тона), `extract-channel` (выбор R/G/B в оттенках серого), `swap-channels` (перестановка парой), `threshold-black-white` (порог яркости), `posterize` (число уровней), `two-colors` (два цвета + порог), `invert-alpha`.
|
||||
|
||||
**Прозрачность и холст — 7**
|
||||
`remove-alpha-channel` (подложка белым), `set-alpha-channel` (константная прозрачность), `extract-alpha-mask` (альфа в чёрно-белую маску), `add-padding` (отступ + цвет/прозрачность), `add-border` (толщина + цвет), `round-corners` (радиус), `fit-on-background` (вписать на полотно цвета с полями).
|
||||
|
||||
**Композиция — 2**
|
||||
`tile` (повтор по сетке), `center-by-alpha` (центрирование по непрозрачному содержимому).
|
||||
|
||||
**Генераторы — 4**
|
||||
`create-empty` (размер + цвет или прозрачность), `single-color`, `random-noise` (с фиксированным зерном для воспроизводимости), `linear-gradient` (два цвета, направление).
|
||||
|
||||
**Анализ — 3**
|
||||
`is-grayscale`, `is-transparent`, `orientation` (портрет/ландшафт/квадрат) — текстовые сводки в info-режиме.
|
||||
|
||||
## 4. Новые операции ядра (все — чистые функции с тестами)
|
||||
|
||||
- Холст: расширение полотна (общая база для padding/border/fit), тайлинг, центр по bounding-box альфы, скругление углов маской радиуса.
|
||||
- Цвет: сепия, сдвиг тона через HSL, множитель альфы, извлечение/перестановка каналов, пороги (яркость и двухцветный), постеризация уровней, инверсия альфы.
|
||||
- Генерация: заливка цветом, шум с зерном, линейный градиент.
|
||||
- Форматы: BMP-энкодер; кодеки текстовых представлений (base64/data-uri/hex в обе стороны).
|
||||
- Анализ: проверка монохромности, полной непрозрачности, ориентации.
|
||||
|
||||
## 5. Этапы и проверки
|
||||
|
||||
- **A. Механизмы** — генераторный режим страницы, текстовый ввод/вывод, BMP-энкодер. Проверка: `test/check/build`.
|
||||
- **B. Конвертация и текстовые представления** — 12 инструментов. Проверка: ручной прогон каждой конвертации туда-обратно.
|
||||
- **C. Цвет** — 9 инструментов. Проверка: юнит-тесты операций + визуально.
|
||||
- **D. Прозрачность, холст, композиция** — 9 инструментов. Проверка: юнит-тесты + визуально.
|
||||
- **E. Генераторы и анализ** — 7 инструментов. Проверка: детерминизм шума (одно зерно — одно изображение), своды анализа.
|
||||
- **F. Итог** — полный прогон, смоук всех новых инструментов «настроить → скачать», регресс старых одиннадцати.
|
||||
|
||||
## 6. Критерии готовности
|
||||
|
||||
- Каталог содержит ~48 рабочих инструментов, каждый доступен страницей с формы из реестра.
|
||||
- Все новые операции ядра покрыты тестами; обратимые конвертации проверены круговым прогоном (например PNG → base64 → PNG даёт те же пиксели).
|
||||
- Генераторы работают без загрузки файла; текстовые представления копируются и скачиваются.
|
||||
- `test/check/build` зелёные.
|
||||
|
||||
## 7. Что сознательно не делаем
|
||||
|
||||
- SVG-вход, рисование текста и водяных знаков, эффекты через фильтры контекста, GIF/APNG-энкодинг, пакетная обработка — последующие волны.
|
||||
- Семейства logo/icon/signature/stamp как отдельные страницы — они будут конфигурациями тех же операций после этой волны.
|
||||
+1
-3
@@ -1,7 +1,5 @@
|
||||
# План разработки: easy-png-tools
|
||||
|
||||
Сопутствующий документ: [analysis.md](./analysis.md) — перечень и уровни утилит.
|
||||
|
||||
## 0. Решения
|
||||
|
||||
- **Набор PNG-утилит, всё в браузере** (SvelteKit + `adapter-static`), данные не покидают машину.
|
||||
@@ -41,7 +39,7 @@ easy-png-tools/
|
||||
|
||||
1.4 **Пайплайн-workspace** (`/workspace`): загрузил изображение → список шагов (инструмент + его параметры) → последовательное применение с превью каждого шага → скачивание финального результата. Добавление/удаление/перестановка шагов. Сохранение пайплайнов в localStorage, экспорт/импорт JSON. Общие компоненты: DropZone, Preview, ParamForm, Download, PipelineSteps.
|
||||
|
||||
1.5 **Массовая реализация EASY-инструментов** по категориям из analysis.md:
|
||||
1.5 **Массовая реализация EASY-инструментов** по категориям:
|
||||
|
||||
- конвертация форматов (png/jpg/webp/bmp/base64/gif) и текстовые представления;
|
||||
- прозрачность и альфа-канал;
|
||||
|
||||
+2
-1
@@ -4,7 +4,8 @@
|
||||
"description": "",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"test": "echo \"Error: no test specified\" && exit 1"
|
||||
"test": "echo \"Error: no test specified\" && exit 1",
|
||||
"dev": "pnpm --dir web dev"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "Ku6epXBOCTuK",
|
||||
|
||||
Generated
+208
@@ -0,0 +1,208 @@
|
||||
---
|
||||
lockfileVersion: '9.0'
|
||||
|
||||
importers:
|
||||
|
||||
.:
|
||||
configDependencies: {}
|
||||
packageManagerDependencies:
|
||||
'@pnpm/exe':
|
||||
specifier: ^11.20.0
|
||||
version: 11.22.0
|
||||
pnpm:
|
||||
specifier: ^11.20.0
|
||||
version: 11.22.0
|
||||
|
||||
packages:
|
||||
|
||||
'@pnpm/exe@11.22.0':
|
||||
resolution: {integrity: sha512-B1SGeKm+v9pX9YkzMmrnO2FbgBd8TDwzZ3jSj6J6ThxdyGvI4TsOfMeHARW4Wb25visPpmMDfIXrU76EZdJM4g==}
|
||||
hasBin: true
|
||||
|
||||
'@pnpm/linux-arm64@11.22.0':
|
||||
resolution: {integrity: sha512-xzzn3jYG9QaiFZPaHcWM3yX4Fm9UGz5E42mzpbvUEvXYf5O9fwNolenOhGLpRl2qS5u35fjZSvDZbWlOwHMcug==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
|
||||
'@pnpm/linux-x64@11.22.0':
|
||||
resolution: {integrity: sha512-isvaPctGinbsM2hsTtRsMarN8Sr5QhXDTNmn8Xv9Lp1PjincCvH2RBbhpo+xYIOxzgls1dtQSdgoORUbfRVALQ==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
|
||||
'@pnpm/linuxstatic-arm64@11.22.0':
|
||||
resolution: {integrity: sha512-i4J+AQWW0T3JBdXaLsvxu8ZmMG1HLS6JZQESdOR9uBv8Zumb4yg8GYT83eWRLacr6ngVdhEBiC3W4eOG64MFbA==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@pnpm/linuxstatic-x64@11.22.0':
|
||||
resolution: {integrity: sha512-QYzk8jhSuSbVthW/OxEOhU3f3zhjpw4HqgedrlwbVNb7btCWn5del2Hb5PsYYka2PbmKq5EtF5IzYN8Yp1CfxQ==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@pnpm/macos-arm64@11.22.0':
|
||||
resolution: {integrity: sha512-Io8Axk5kutPgMuAfOg1QGj3J0/KpLvH5iiPcmp7up8D4q7BNWT02ndQo3RyGWqrlkf5nwspM41GFpWTShrZ4Aw==}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@pnpm/win-arm64@11.22.0':
|
||||
resolution: {integrity: sha512-QgaRuKGQKov7xW2utPCgDT83fn/PU5cD6HFgib48oTslz7wv26E89d4jMCxL4GRmEL2YCfkYuj5ITj1oVWg5WQ==}
|
||||
cpu: [arm64]
|
||||
os: [win32]
|
||||
|
||||
'@pnpm/win-x64@11.22.0':
|
||||
resolution: {integrity: sha512-iWYsiSwpgxqur+TwsnSoPdOQaEfd4ygB84mb5tJUOgim6PHr1xhKJBndaQzH+At/WkLxLdYN+K8dYc4M7naezg==}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@reflink/reflink-darwin-arm64@0.1.19':
|
||||
resolution: {integrity: sha512-ruy44Lpepdk1FqDz38vExBY/PVUsjxZA+chd9wozjUH9JjuDT/HEaQYA6wYN9mf041l0yLVar6BCZuWABJvHSA==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@reflink/reflink-darwin-x64@0.1.19':
|
||||
resolution: {integrity: sha512-By85MSWrMZa+c26TcnAy8SDk0sTUkYlNnwknSchkhHpGXOtjNDUOxJE9oByBnGbeuIE1PiQsxDG3Ud+IVV9yuA==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
'@reflink/reflink-linux-arm64-gnu@0.1.19':
|
||||
resolution: {integrity: sha512-7P+er8+rP9iNeN+bfmccM4hTAaLP6PQJPKWSA4iSk2bNvo6KU6RyPgYeHxXmzNKzPVRcypZQTpFgstHam6maVg==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@reflink/reflink-linux-arm64-musl@0.1.19':
|
||||
resolution: {integrity: sha512-37iO/Dp6m5DDaC2sf3zPtx/hl9FV3Xze4xoYidrxxS9bgP3S8ALroxRK6xBG/1TtfXKTvolvp+IjrUU6ujIGmA==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@reflink/reflink-linux-x64-gnu@0.1.19':
|
||||
resolution: {integrity: sha512-jbI8jvuYCaA3MVUdu8vLoLAFqC+iNMpiSuLbxlAgg7x3K5bsS8nOpTRnkLF7vISJ+rVR8W+7ThXlXlUQ93ulkw==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@reflink/reflink-linux-x64-musl@0.1.19':
|
||||
resolution: {integrity: sha512-e9FBWDe+lv7QKAwtKOt6A2W/fyy/aEEfr0g6j/hWzvQcrzHCsz07BNQYlNOjTfeytrtLU7k449H1PI95jA4OjQ==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@reflink/reflink-win32-arm64-msvc@0.1.19':
|
||||
resolution: {integrity: sha512-09PxnVIQcd+UOn4WAW73WU6PXL7DwGS6wPlkMhMg2zlHHG65F3vHepOw06HFCq+N42qkaNAc8AKIabWvtk6cIQ==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [win32]
|
||||
|
||||
'@reflink/reflink-win32-x64-msvc@0.1.19':
|
||||
resolution: {integrity: sha512-E//yT4ni2SyhwP8JRjVGWr3cbnhWDiPLgnQ66qqaanjjnMiu3O/2tjCPQXlcGc/DEYofpDc9fvhv6tALQsMV9w==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@reflink/reflink@0.1.19':
|
||||
resolution: {integrity: sha512-DmCG8GzysnCZ15bres3N5AHCmwBwYgp0As6xjhQ47rAUTUXxJiK+lLUxaGsX3hd/30qUpVElh05PbGuxRPgJwA==}
|
||||
engines: {node: '>= 10'}
|
||||
|
||||
detect-libc@2.1.2:
|
||||
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
pnpm@11.22.0:
|
||||
resolution: {integrity: sha512-H/hwxMYTPf2I+yr8Rt0T1H8JyXlLQ4xv20fKmMrzvBY4HuC+k6CRuOOCTPAfiJ9G19niCRD7C+GrD7W6qA3WIQ==}
|
||||
engines: {node: '>=22.13'}
|
||||
hasBin: true
|
||||
|
||||
snapshots:
|
||||
|
||||
'@pnpm/exe@11.22.0':
|
||||
dependencies:
|
||||
'@reflink/reflink': 0.1.19
|
||||
detect-libc: 2.1.2
|
||||
optionalDependencies:
|
||||
'@pnpm/linux-arm64': 11.22.0
|
||||
'@pnpm/linux-x64': 11.22.0
|
||||
'@pnpm/linuxstatic-arm64': 11.22.0
|
||||
'@pnpm/linuxstatic-x64': 11.22.0
|
||||
'@pnpm/macos-arm64': 11.22.0
|
||||
'@pnpm/win-arm64': 11.22.0
|
||||
'@pnpm/win-x64': 11.22.0
|
||||
|
||||
'@pnpm/linux-arm64@11.22.0':
|
||||
optional: true
|
||||
|
||||
'@pnpm/linux-x64@11.22.0':
|
||||
optional: true
|
||||
|
||||
'@pnpm/linuxstatic-arm64@11.22.0':
|
||||
optional: true
|
||||
|
||||
'@pnpm/linuxstatic-x64@11.22.0':
|
||||
optional: true
|
||||
|
||||
'@pnpm/macos-arm64@11.22.0':
|
||||
optional: true
|
||||
|
||||
'@pnpm/win-arm64@11.22.0':
|
||||
optional: true
|
||||
|
||||
'@pnpm/win-x64@11.22.0':
|
||||
optional: true
|
||||
|
||||
'@reflink/reflink-darwin-arm64@0.1.19':
|
||||
optional: true
|
||||
|
||||
'@reflink/reflink-darwin-x64@0.1.19':
|
||||
optional: true
|
||||
|
||||
'@reflink/reflink-linux-arm64-gnu@0.1.19':
|
||||
optional: true
|
||||
|
||||
'@reflink/reflink-linux-arm64-musl@0.1.19':
|
||||
optional: true
|
||||
|
||||
'@reflink/reflink-linux-x64-gnu@0.1.19':
|
||||
optional: true
|
||||
|
||||
'@reflink/reflink-linux-x64-musl@0.1.19':
|
||||
optional: true
|
||||
|
||||
'@reflink/reflink-win32-arm64-msvc@0.1.19':
|
||||
optional: true
|
||||
|
||||
'@reflink/reflink-win32-x64-msvc@0.1.19':
|
||||
optional: true
|
||||
|
||||
'@reflink/reflink@0.1.19':
|
||||
optionalDependencies:
|
||||
'@reflink/reflink-darwin-arm64': 0.1.19
|
||||
'@reflink/reflink-darwin-x64': 0.1.19
|
||||
'@reflink/reflink-linux-arm64-gnu': 0.1.19
|
||||
'@reflink/reflink-linux-arm64-musl': 0.1.19
|
||||
'@reflink/reflink-linux-x64-gnu': 0.1.19
|
||||
'@reflink/reflink-linux-x64-musl': 0.1.19
|
||||
'@reflink/reflink-win32-arm64-msvc': 0.1.19
|
||||
'@reflink/reflink-win32-x64-msvc': 0.1.19
|
||||
|
||||
detect-libc@2.1.2: {}
|
||||
|
||||
pnpm@11.22.0: {}
|
||||
|
||||
---
|
||||
lockfileVersion: '9.0'
|
||||
|
||||
settings:
|
||||
autoInstallPeers: true
|
||||
excludeLinksFromLockfile: false
|
||||
|
||||
importers:
|
||||
|
||||
.: {}
|
||||
@@ -0,0 +1,23 @@
|
||||
node_modules
|
||||
|
||||
# Output
|
||||
.output
|
||||
.vercel
|
||||
.netlify
|
||||
.wrangler
|
||||
/.svelte-kit
|
||||
/build
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Env
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
!.env.test
|
||||
|
||||
# Vite
|
||||
vite.config.js.timestamp-*
|
||||
vite.config.ts.timestamp-*
|
||||
@@ -0,0 +1 @@
|
||||
engine-strict=true
|
||||
Vendored
+3
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"recommendations": ["svelte.svelte-vscode"]
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
# sv
|
||||
|
||||
Everything you need to build a Svelte project, powered by [`sv`](https://github.com/sveltejs/cli).
|
||||
|
||||
## Creating a project
|
||||
|
||||
If you're seeing this, you've probably already done this step. Congrats!
|
||||
|
||||
```sh
|
||||
# create a new project
|
||||
npx sv create my-app
|
||||
```
|
||||
|
||||
To recreate this project with the same configuration:
|
||||
|
||||
```sh
|
||||
# recreate this project
|
||||
pnpm dlx sv@0.17.0 create --template minimal --types ts --install pnpm web
|
||||
```
|
||||
|
||||
## Developing
|
||||
|
||||
Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server:
|
||||
|
||||
```sh
|
||||
npm run dev
|
||||
|
||||
# or start the server and open the app in a new browser tab
|
||||
npm run dev -- --open
|
||||
```
|
||||
|
||||
## Building
|
||||
|
||||
To create a production version of your app:
|
||||
|
||||
```sh
|
||||
npm run build
|
||||
```
|
||||
|
||||
You can preview the production build with `npm run preview`.
|
||||
|
||||
> To deploy your app, you may need to install an [adapter](https://svelte.dev/docs/kit/adapters) for your target environment.
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"name": "web",
|
||||
"private": true,
|
||||
"version": "0.0.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"prepare": "svelte-kit sync || echo ''",
|
||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/adapter-static": "^3.0.10",
|
||||
"@sveltejs/kit": "^2.63.0",
|
||||
"@sveltejs/vite-plugin-svelte": "^7.1.2",
|
||||
"svelte": "^5.56.1",
|
||||
"svelte-check": "^4.6.0",
|
||||
"typescript": "^6.0.3",
|
||||
"vite": "^8.0.16",
|
||||
"vitest": "^4.1.11"
|
||||
}
|
||||
}
|
||||
Generated
+1040
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,2 @@
|
||||
allowBuilds:
|
||||
esbuild: true
|
||||
+156
@@ -0,0 +1,156 @@
|
||||
:root {
|
||||
--bg: #f6f7f9;
|
||||
--surface: #ffffff;
|
||||
--text: #1b1e24;
|
||||
--text-muted: #5c6470;
|
||||
--accent: #2563eb;
|
||||
--accent-hover: #1d4ed8;
|
||||
--accent-contrast: #ffffff;
|
||||
--border: #dfe2e8;
|
||||
--danger: #e5484d;
|
||||
--danger-strong: #b3261e;
|
||||
--check-a: #eceff3;
|
||||
--check-b: #ffffff;
|
||||
|
||||
--space-1: 0.25rem;
|
||||
--space-2: 0.5rem;
|
||||
--space-3: 1rem;
|
||||
--space-4: 1.5rem;
|
||||
--space-5: 2.5rem;
|
||||
|
||||
--radius-s: 6px;
|
||||
--radius-m: 10px;
|
||||
|
||||
--text-xs: 0.8rem;
|
||||
--text-s: 0.85rem;
|
||||
--text-m: 0.95rem;
|
||||
--text-l: 1.1rem;
|
||||
--text-xl: 1.25rem;
|
||||
--text-xxl: 1.75rem;
|
||||
--leading-tight: 1.25;
|
||||
|
||||
--shadow-card: 0 4px 14px rgb(37 99 235 / 12%);
|
||||
--transition-fast: 120ms ease;
|
||||
|
||||
--control-height: 2.4rem;
|
||||
--control-max-width: 16rem;
|
||||
|
||||
--font-sans:
|
||||
system-ui, -apple-system, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
--font-mono: ui-monospace, 'Cascadia Code', Consolas, monospace;
|
||||
}
|
||||
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
html {
|
||||
font-family: var(--font-sans);
|
||||
color: var(--text);
|
||||
background: var(--bg);
|
||||
line-height: 1.55;
|
||||
-webkit-text-size-adjust: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
min-height: 100dvh;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: var(--text-xxl);
|
||||
line-height: var(--leading-tight);
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: var(--text-xl);
|
||||
line-height: var(--leading-tight);
|
||||
}
|
||||
|
||||
p,
|
||||
label {
|
||||
font-size: var(--text-m);
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--accent);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
button,
|
||||
input,
|
||||
select {
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
border-radius: var(--radius-s);
|
||||
}
|
||||
|
||||
.text-muted {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.text-caption {
|
||||
font-size: var(--text-s);
|
||||
}
|
||||
|
||||
.heading-section {
|
||||
font-size: var(--text-l);
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.panel {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-m);
|
||||
}
|
||||
|
||||
.control {
|
||||
padding: var(--space-1) var(--space-2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-s);
|
||||
background: var(--surface);
|
||||
max-width: var(--control-max-width);
|
||||
}
|
||||
|
||||
input.control[type='number'] {
|
||||
font-family: var(--font-mono);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.error-banner {
|
||||
padding: var(--space-2) var(--space-3);
|
||||
border: 1px solid var(--danger);
|
||||
border-radius: var(--radius-s);
|
||||
background: color-mix(in srgb, var(--danger) 8%, var(--surface));
|
||||
color: var(--danger-strong);
|
||||
font-size: var(--text-s);
|
||||
}
|
||||
|
||||
.visually-hidden {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
margin: -1px;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
clip: rect(0 0 0 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
Vendored
+13
@@ -0,0 +1,13 @@
|
||||
// See https://svelte.dev/docs/kit/types#app.d.ts
|
||||
// for information about these interfaces
|
||||
declare global {
|
||||
namespace App {
|
||||
// interface Error {}
|
||||
// interface Locals {}
|
||||
// interface PageData {}
|
||||
// interface PageState {}
|
||||
// interface Platform {}
|
||||
}
|
||||
}
|
||||
|
||||
export {};
|
||||
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="text-scale" content="scale" />
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
<div style="display: contents">%sveltekit.body%</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="107" height="128" viewBox="0 0 107 128"><title>svelte-logo</title><path d="M94.157 22.819c-10.4-14.885-30.94-19.297-45.792-9.835L22.282 29.608A29.92 29.92 0 0 0 8.764 49.65a31.5 31.5 0 0 0 3.108 20.231 30 30 0 0 0-4.477 11.183 31.9 31.9 0 0 0 5.448 24.116c10.402 14.887 30.942 19.297 45.791 9.835l26.083-16.624A29.92 29.92 0 0 0 98.235 78.35a31.53 31.53 0 0 0-3.105-20.232 30 30 0 0 0 4.474-11.182 31.88 31.88 0 0 0-5.447-24.116" style="fill:#ff3e00"/><path d="M45.817 106.582a20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.503 18 18 0 0 1 .624-2.435l.49-1.498 1.337.981a33.6 33.6 0 0 0 10.203 5.098l.97.294-.09.968a5.85 5.85 0 0 0 1.052 3.878 6.24 6.24 0 0 0 6.695 2.485 5.8 5.8 0 0 0 1.603-.704L69.27 76.28a5.43 5.43 0 0 0 2.45-3.631 5.8 5.8 0 0 0-.987-4.371 6.24 6.24 0 0 0-6.698-2.487 5.7 5.7 0 0 0-1.6.704l-9.953 6.345a19 19 0 0 1-5.296 2.326 20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.502 17.99 17.99 0 0 1 8.13-12.052l26.081-16.623a19 19 0 0 1 5.3-2.329 20.72 20.72 0 0 1 22.237 8.243 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-.624 2.435l-.49 1.498-1.337-.98a33.6 33.6 0 0 0-10.203-5.1l-.97-.294.09-.968a5.86 5.86 0 0 0-1.052-3.878 6.24 6.24 0 0 0-6.696-2.485 5.8 5.8 0 0 0-1.602.704L37.73 51.72a5.42 5.42 0 0 0-2.449 3.63 5.79 5.79 0 0 0 .986 4.372 6.24 6.24 0 0 0 6.698 2.486 5.8 5.8 0 0 0 1.602-.704l9.952-6.342a19 19 0 0 1 5.295-2.328 20.72 20.72 0 0 1 22.237 8.242 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-8.13 12.053l-26.081 16.622a19 19 0 0 1-5.3 2.328" style="fill:#fff"/></svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
@@ -0,0 +1,11 @@
|
||||
export type CategoryId = 'convert' | 'alpha' | 'color' | 'geometry' | 'analyze';
|
||||
|
||||
export type Category = { id: CategoryId; label: string };
|
||||
|
||||
export const CATEGORIES: Category[] = [
|
||||
{ id: 'convert', label: 'Конвертация' },
|
||||
{ id: 'alpha', label: 'Прозрачность' },
|
||||
{ id: 'color', label: 'Цвет' },
|
||||
{ id: 'geometry', label: 'Геометрия' },
|
||||
{ id: 'analyze', label: 'Анализ' }
|
||||
];
|
||||
@@ -0,0 +1,45 @@
|
||||
<script lang="ts">
|
||||
import Button from './ui/Button.svelte';
|
||||
import { downloadBlob, encode } from '$lib/core/io';
|
||||
import type { PixelImage } from '$lib/core/types';
|
||||
import type { OutputFormat } from '$lib/registry';
|
||||
|
||||
interface Props {
|
||||
image: PixelImage | null;
|
||||
format?: OutputFormat;
|
||||
baseName: string;
|
||||
params: Record<string, unknown>;
|
||||
onError?: (e: unknown) => void;
|
||||
}
|
||||
|
||||
let { image, format, baseName, params, onError }: Props = $props();
|
||||
|
||||
let busy = $state(false);
|
||||
|
||||
async function download() {
|
||||
if (!image || !format || busy) return;
|
||||
busy = true;
|
||||
try {
|
||||
const raw = format.qualityParamId ? params[format.qualityParamId] : undefined;
|
||||
const quality =
|
||||
typeof raw === 'number' ? Math.min(Math.max(raw, 1), 100) / 100 : undefined;
|
||||
const blob = await encode(image, format.mime, quality);
|
||||
downloadBlob(blob, `${baseName}.${format.ext}`);
|
||||
} catch (e) {
|
||||
onError?.(e);
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<Button
|
||||
variant="primary"
|
||||
fullWidth
|
||||
disabled={!image || !format}
|
||||
{busy}
|
||||
busyText="Готовим файл…"
|
||||
onclick={download}
|
||||
>
|
||||
Скачать .{format?.ext ?? 'png'}
|
||||
</Button>
|
||||
@@ -0,0 +1,96 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
import { isSupportedImage, unsupportedImageMessage } from '$lib/core/io';
|
||||
|
||||
interface Props {
|
||||
onFile: (file: File) => void;
|
||||
onError?: (message: string) => void;
|
||||
label?: string;
|
||||
children: Snippet;
|
||||
}
|
||||
|
||||
let {
|
||||
onFile,
|
||||
onError,
|
||||
label = 'Отпустите файл, чтобы заменить изображение',
|
||||
children
|
||||
}: Props = $props();
|
||||
|
||||
let depth = $state(0);
|
||||
const dragging = $derived(depth > 0);
|
||||
|
||||
function enter() {
|
||||
depth += 1;
|
||||
}
|
||||
|
||||
function leave() {
|
||||
depth = Math.max(0, depth - 1);
|
||||
}
|
||||
|
||||
function over(event: DragEvent) {
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
function end() {
|
||||
depth = 0;
|
||||
}
|
||||
|
||||
function drop(event: DragEvent) {
|
||||
event.preventDefault();
|
||||
depth = 0;
|
||||
const file = event.dataTransfer?.files[0];
|
||||
if (!file) return;
|
||||
if (!isSupportedImage(file)) {
|
||||
onError?.(unsupportedImageMessage(file));
|
||||
return;
|
||||
}
|
||||
onFile(file);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="area"
|
||||
class:dragging
|
||||
role="region"
|
||||
aria-label={label}
|
||||
ondragenter={enter}
|
||||
ondragleave={leave}
|
||||
ondragover={over}
|
||||
ondragend={end}
|
||||
ondrop={drop}
|
||||
>
|
||||
{@render children()}
|
||||
{#if dragging}
|
||||
<div class="veil" aria-hidden="true">
|
||||
<p>{label}</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.area {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.veil {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 10;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: var(--space-3);
|
||||
background: color-mix(in srgb, var(--accent) 10%, var(--surface));
|
||||
border: 2px dashed var(--accent);
|
||||
border-radius: var(--radius-m);
|
||||
color: var(--accent);
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
pointer-events: none;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,116 @@
|
||||
<script lang="ts">
|
||||
import { ACCEPTED_IMAGE_TYPES, isSupportedImage, unsupportedImageMessage } from '$lib/core/io';
|
||||
|
||||
interface Props {
|
||||
onFile: (file: File) => void;
|
||||
onError?: (message: string) => void;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
let { onFile, onError, label = 'Перетащите изображение сюда или нажмите, чтобы выбрать файл' }: Props =
|
||||
$props();
|
||||
|
||||
let input = $state<HTMLInputElement | undefined>();
|
||||
let depth = $state(0);
|
||||
const dragging = $derived(depth > 0);
|
||||
|
||||
function enter() {
|
||||
depth += 1;
|
||||
}
|
||||
|
||||
function leave() {
|
||||
depth = Math.max(0, depth - 1);
|
||||
}
|
||||
|
||||
function over(event: DragEvent) {
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
function end() {
|
||||
depth = 0;
|
||||
}
|
||||
|
||||
function accept(file: File | undefined | null) {
|
||||
if (!file) return;
|
||||
if (!isSupportedImage(file)) {
|
||||
onError?.(unsupportedImageMessage(file));
|
||||
return;
|
||||
}
|
||||
onFile(file);
|
||||
}
|
||||
|
||||
function openPicker() {
|
||||
input?.click();
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="dropzone panel"
|
||||
class:dragging
|
||||
role="button"
|
||||
tabindex="0"
|
||||
aria-label={label}
|
||||
onclick={openPicker}
|
||||
onkeydown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
openPicker();
|
||||
}
|
||||
}}
|
||||
ondragenter={enter}
|
||||
ondragleave={leave}
|
||||
ondragover={over}
|
||||
ondragend={end}
|
||||
ondrop={(e) => {
|
||||
e.preventDefault();
|
||||
depth = 0;
|
||||
accept(e.dataTransfer?.files[0]);
|
||||
}}
|
||||
>
|
||||
<span aria-hidden="true" class="icon">⬇</span>
|
||||
{label}
|
||||
</div>
|
||||
<input
|
||||
bind:this={input}
|
||||
type="file"
|
||||
accept={ACCEPTED_IMAGE_TYPES}
|
||||
hidden
|
||||
onchange={() => {
|
||||
accept(input?.files?.[0]);
|
||||
if (input) {
|
||||
input.value = '';
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<style>
|
||||
.dropzone {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--space-2);
|
||||
min-height: 14rem;
|
||||
padding: var(--space-4);
|
||||
border-style: dashed;
|
||||
color: var(--text-muted);
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
transition:
|
||||
border-color var(--transition-fast),
|
||||
background var(--transition-fast);
|
||||
}
|
||||
|
||||
.dropzone:hover,
|
||||
.dropzone:focus-visible,
|
||||
.dragging {
|
||||
border-color: var(--accent);
|
||||
background: color-mix(in srgb, var(--accent) 6%, var(--surface));
|
||||
}
|
||||
|
||||
.icon {
|
||||
font-size: 1.8rem;
|
||||
line-height: 1;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,50 @@
|
||||
<script lang="ts">
|
||||
import type { ImageInfo } from '$lib/core/analyze';
|
||||
|
||||
interface Props {
|
||||
info: ImageInfo | null;
|
||||
}
|
||||
|
||||
let { info }: Props = $props();
|
||||
</script>
|
||||
|
||||
{#if info}
|
||||
<dl>
|
||||
<div class="panel">
|
||||
<dt>Размеры</dt>
|
||||
<dd>{info.width} × {info.height} px</dd>
|
||||
</div>
|
||||
<div class="panel">
|
||||
<dt>Альфа-канал</dt>
|
||||
<dd>{info.hasAlpha ? 'есть — есть полупрозрачные пиксели' : 'нет'}</dd>
|
||||
</div>
|
||||
<div class="panel">
|
||||
<dt>Уникальных цветов (RGBA)</dt>
|
||||
<dd>{info.colorCount.toLocaleString('ru-RU')}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
dl {
|
||||
display: grid;
|
||||
gap: var(--space-2);
|
||||
margin: var(--space-3) 0;
|
||||
}
|
||||
|
||||
dl > div {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
}
|
||||
|
||||
dt {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
dd {
|
||||
margin: 0;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,60 @@
|
||||
<script lang="ts">
|
||||
import CheckboxField from './ui/CheckboxField.svelte';
|
||||
import ColorField from './ui/ColorField.svelte';
|
||||
import SelectField from './ui/SelectField.svelte';
|
||||
import SliderField from './ui/SliderField.svelte';
|
||||
import TextField from './ui/TextField.svelte';
|
||||
import type { ParamDef } from '$lib/registry';
|
||||
|
||||
interface Props {
|
||||
params: ParamDef[];
|
||||
values: Record<string, any>;
|
||||
pipetteTargetId?: string | null;
|
||||
onPipetteToggle?: (id: string) => void;
|
||||
}
|
||||
|
||||
let { params, values = $bindable(), pipetteTargetId = null, onPipetteToggle }: 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}
|
||||
@@ -0,0 +1,109 @@
|
||||
<script lang="ts">
|
||||
import { rgbToHex } from '$lib/core/color';
|
||||
import type { PixelImage } from '$lib/core/types';
|
||||
import PipetteLoupe from './tool/PipetteLoupe.svelte';
|
||||
|
||||
interface Props {
|
||||
image: PixelImage | null;
|
||||
pipetteActive?: boolean;
|
||||
onPickColor?: (hex: string) => void;
|
||||
}
|
||||
|
||||
let { image, pipetteActive = false, onPickColor }: Props = $props();
|
||||
|
||||
let canvas = $state<HTMLCanvasElement | undefined>();
|
||||
let hover = $state<{ clientX: number; clientY: number; px: number; py: number; hex: string } | null>(
|
||||
null
|
||||
);
|
||||
|
||||
$effect(() => {
|
||||
if (!canvas || !image) return;
|
||||
canvas.width = image.width;
|
||||
canvas.height = image.height;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
ctx.putImageData(new ImageData(image.data, image.width, image.height), 0, 0);
|
||||
});
|
||||
|
||||
function pixelAt(event: MouseEvent): { px: number; py: number; hex: string } | null {
|
||||
if (!canvas || !image) return null;
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
if (rect.width === 0 || rect.height === 0) return null;
|
||||
const px = Math.floor((event.clientX - rect.left) * (canvas.width / rect.width));
|
||||
const py = Math.floor((event.clientY - rect.top) * (canvas.height / rect.height));
|
||||
if (px < 0 || py < 0 || px >= canvas.width || py >= canvas.height) return null;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return null;
|
||||
const [r, g, b] = ctx.getImageData(px, py, 1, 1).data;
|
||||
return { px, py, hex: rgbToHex(r, g, b) };
|
||||
}
|
||||
|
||||
function pickColor(event: MouseEvent) {
|
||||
if (!pipetteActive || !onPickColor) return;
|
||||
const pixel = pixelAt(event);
|
||||
if (pixel) {
|
||||
onPickColor(pixel.hex);
|
||||
}
|
||||
}
|
||||
|
||||
function trackHover(event: MouseEvent) {
|
||||
if (!pipetteActive) {
|
||||
hover = null;
|
||||
return;
|
||||
}
|
||||
const pixel = pixelAt(event);
|
||||
hover = pixel
|
||||
? { clientX: event.clientX, clientY: event.clientY, px: pixel.px, py: pixel.py, hex: pixel.hex }
|
||||
: null;
|
||||
}
|
||||
|
||||
function clearHover() {
|
||||
hover = null;
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if image}
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<canvas
|
||||
bind:this={canvas}
|
||||
title="{image.width} × {image.height}"
|
||||
class:pipette={pipetteActive}
|
||||
onclick={pickColor}
|
||||
onmousemove={trackHover}
|
||||
onmouseleave={clearHover}
|
||||
></canvas>
|
||||
<p class="dims">{image.width} × {image.height} px</p>
|
||||
{#if pipetteActive && hover && image}
|
||||
<PipetteLoupe
|
||||
{image}
|
||||
px={hover.px}
|
||||
py={hover.py}
|
||||
hex={hover.hex}
|
||||
clientX={hover.clientX}
|
||||
clientY={hover.clientY}
|
||||
/>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
canvas {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
max-height: 32rem;
|
||||
background:
|
||||
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 {
|
||||
cursor: crosshair;
|
||||
}
|
||||
|
||||
.dims {
|
||||
margin-top: var(--space-1);
|
||||
color: var(--text-muted);
|
||||
font-size: var(--text-s);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,272 @@
|
||||
<script lang="ts">
|
||||
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 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();
|
||||
|
||||
type Status = 'idle' | 'loaded' | 'processing' | 'error';
|
||||
|
||||
let status = $state<Status>('idle');
|
||||
let source = $state<PixelImage | null>(null);
|
||||
let result = $state<PixelImage | null>(null);
|
||||
let previewResult = $state<PixelImage | null>(null);
|
||||
let textResult = $state<string | null>(null);
|
||||
let showMask = $state(false);
|
||||
let info = $state<ImageInfo | null>(null);
|
||||
let errorText = $state('');
|
||||
let values = $state<Record<string, any>>({});
|
||||
|
||||
const isInfo = $derived(tool.resultType === 'info');
|
||||
const isSourceless = $derived(tool.sourceMode === 'none');
|
||||
const isTextSource = $derived(tool.sourceMode === 'text');
|
||||
const sanitized = $derived(sanitizeParams(tool, values));
|
||||
|
||||
let runToken = 0;
|
||||
let hasLastRun = false;
|
||||
let lastRunSource: PixelImage | null = null;
|
||||
let lastRunValuesJson = '';
|
||||
let pipetteTargetId = $state<string | null>(null);
|
||||
|
||||
function handlePipetteToggle(id: string) {
|
||||
pipetteTargetId = pipetteTargetId === id ? null : id;
|
||||
}
|
||||
|
||||
function handlePickColor(hex: string) {
|
||||
if (!pipetteTargetId) return;
|
||||
values[pipetteTargetId] = hex;
|
||||
pipetteTargetId = null;
|
||||
}
|
||||
|
||||
async function handleFile(file: File) {
|
||||
errorText = '';
|
||||
status = 'processing';
|
||||
try {
|
||||
source = await decodeFile(file);
|
||||
values = defaultParams(tool);
|
||||
result = null;
|
||||
previewResult = null;
|
||||
showMask = false;
|
||||
pipetteTargetId = null;
|
||||
info = isInfo ? imageInfo(source) : null;
|
||||
if (isInfo) {
|
||||
status = 'loaded';
|
||||
} else {
|
||||
await runTool();
|
||||
}
|
||||
} catch (e) {
|
||||
showError(e);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleTextSubmit(text: string) {
|
||||
if (!isTextSource || !tool.runFromText) return;
|
||||
errorText = '';
|
||||
status = 'processing';
|
||||
try {
|
||||
source = await tool.runFromText(text, defaultParams(tool));
|
||||
values = defaultParams(tool);
|
||||
result = null;
|
||||
textResult = null;
|
||||
previewResult = null;
|
||||
showMask = false;
|
||||
pipetteTargetId = null;
|
||||
info = null;
|
||||
await runTool();
|
||||
} catch (e) {
|
||||
showError(e);
|
||||
}
|
||||
}
|
||||
|
||||
async function runTool() {
|
||||
if (isInfo) return;
|
||||
if (!source && !isSourceless) return;
|
||||
const token = ++runToken;
|
||||
hasLastRun = true;
|
||||
lastRunSource = source;
|
||||
lastRunValuesJson = JSON.stringify(sanitized);
|
||||
try {
|
||||
let next: PixelImage | null = null;
|
||||
let nextText: string | null = null;
|
||||
|
||||
if (tool.resultType === 'text') {
|
||||
nextText = await tool.toText!(source!, sanitized);
|
||||
} else if (isSourceless) {
|
||||
next = await tool.generate!(sanitized);
|
||||
} else {
|
||||
next = await tool.run!(source!, sanitized);
|
||||
}
|
||||
|
||||
let nextPreview: PixelImage | null = null;
|
||||
if (tool.preview && source) {
|
||||
try {
|
||||
nextPreview = await tool.preview(source, sanitized);
|
||||
} catch {
|
||||
nextPreview = null;
|
||||
}
|
||||
}
|
||||
|
||||
if (token !== runToken) return;
|
||||
result = next;
|
||||
previewResult = nextPreview;
|
||||
textResult = nextText;
|
||||
status = 'loaded';
|
||||
} catch (e) {
|
||||
if (token !== runToken) return;
|
||||
showError(e);
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
const valuesJson = JSON.stringify(sanitized);
|
||||
if (hasLastRun && source === lastRunSource && valuesJson === lastRunValuesJson) return;
|
||||
if (!source && !isSourceless) return;
|
||||
if (isInfo) return;
|
||||
const timer = setTimeout(() => void runTool(), 300);
|
||||
return () => clearTimeout(timer);
|
||||
});
|
||||
|
||||
function showError(e: unknown) {
|
||||
status = source ? 'loaded' : 'idle';
|
||||
errorText = e instanceof Error ? e.message : String(e);
|
||||
}
|
||||
|
||||
function reset() {
|
||||
source = null;
|
||||
result = null;
|
||||
previewResult = null;
|
||||
textResult = null;
|
||||
showMask = false;
|
||||
pipetteTargetId = null;
|
||||
info = null;
|
||||
errorText = '';
|
||||
status = 'idle';
|
||||
values = defaultParams(tool);
|
||||
}
|
||||
|
||||
function handlePaste(event: ClipboardEvent) {
|
||||
const items = event.clipboardData?.items;
|
||||
if (!items) return;
|
||||
for (const item of items) {
|
||||
if (!item.type.startsWith('image/')) continue;
|
||||
const file = item.getAsFile();
|
||||
if (file) {
|
||||
event.preventDefault();
|
||||
if (!isSupportedImage(file)) {
|
||||
errorText = unsupportedImageMessage(file);
|
||||
return;
|
||||
}
|
||||
handleFile(file);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onpaste={handlePaste} />
|
||||
|
||||
<section>
|
||||
<h1>{tool.title}</h1>
|
||||
<p class="description text-muted">{tool.description}</p>
|
||||
|
||||
{#if errorText}
|
||||
<div class="error-banner" role="alert">{errorText}</div>
|
||||
{/if}
|
||||
|
||||
<div class="stage panel" class:single={isSourceless}>
|
||||
{#if !isSourceless}
|
||||
<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
|
||||
{info}
|
||||
{isInfo}
|
||||
params={sanitized}
|
||||
{textResult}
|
||||
onDownloadError={showError}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if (source || isSourceless) && !isInfo}
|
||||
<ParamsCard
|
||||
params={tool.params}
|
||||
bind:values
|
||||
{pipetteTargetId}
|
||||
onPipetteToggle={handlePipetteToggle}
|
||||
/>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<style>
|
||||
h1 {
|
||||
margin-bottom: var(--space-1);
|
||||
}
|
||||
|
||||
.description {
|
||||
max-width: 48rem;
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
|
||||
.error-banner {
|
||||
margin-bottom: var(--space-3);
|
||||
}
|
||||
|
||||
.stage {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: var(--space-4);
|
||||
padding: var(--space-4);
|
||||
}
|
||||
|
||||
.stage.single {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.cell {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.cell + .cell {
|
||||
border-left: 1px solid var(--border);
|
||||
padding-left: var(--space-4);
|
||||
}
|
||||
|
||||
@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);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,39 @@
|
||||
<script lang="ts">
|
||||
import ParamForm from '../ParamForm.svelte';
|
||||
import type { ParamDef } from '$lib/registry';
|
||||
|
||||
interface Props {
|
||||
params: ParamDef[];
|
||||
values: Record<string, any>;
|
||||
pipetteTargetId?: string | null;
|
||||
onPipetteToggle?: (id: string) => void;
|
||||
}
|
||||
|
||||
let { params, values = $bindable(), pipetteTargetId = null, onPipetteToggle }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="panel root">
|
||||
<h2 class="heading-section">Параметры</h2>
|
||||
{#if params.length > 0}
|
||||
<ParamForm {params} bind:values {pipetteTargetId} {onPipetteToggle} />
|
||||
{:else}
|
||||
<p class="hint text-caption text-muted">
|
||||
У этого инструмента нет параметров — результат уже готов.
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.root {
|
||||
margin-top: var(--space-4);
|
||||
padding: var(--space-4);
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
|
||||
.hint {
|
||||
margin: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,113 @@
|
||||
<script lang="ts">
|
||||
import type { PixelImage } from '$lib/core/types';
|
||||
|
||||
interface Props {
|
||||
image: PixelImage;
|
||||
px: number;
|
||||
py: number;
|
||||
hex: string;
|
||||
clientX: number;
|
||||
clientY: number;
|
||||
}
|
||||
|
||||
let { image, px, py, hex, clientX, clientY }: Props = $props();
|
||||
|
||||
const SIZE = 110;
|
||||
const ZOOM = 8;
|
||||
const BLOCK = 15;
|
||||
const HALF = Math.floor(BLOCK / 2);
|
||||
const CENTER = SIZE / 2;
|
||||
|
||||
let loupeCanvas = $state<HTMLCanvasElement | undefined>();
|
||||
|
||||
let srcCanvas: HTMLCanvasElement | null = null;
|
||||
let srcKey: PixelImage | null = null;
|
||||
|
||||
function ensureSource(): HTMLCanvasElement | null {
|
||||
if (!srcCanvas || srcKey !== image) {
|
||||
srcCanvas = document.createElement('canvas');
|
||||
srcCanvas.width = image.width;
|
||||
srcCanvas.height = image.height;
|
||||
srcCanvas.getContext('2d')?.putImageData(new ImageData(image.data, image.width, image.height), 0, 0);
|
||||
srcKey = image;
|
||||
}
|
||||
return srcCanvas;
|
||||
}
|
||||
|
||||
function clamp(v: number, min: number, max: number): number {
|
||||
return Math.min(max, Math.max(min, v));
|
||||
}
|
||||
|
||||
const blockX = $derived(clamp(px - HALF, 0, Math.max(0, image.width - BLOCK)));
|
||||
const blockY = $derived(clamp(py - HALF, 0, Math.max(0, image.height - BLOCK)));
|
||||
const flipBelow = $derived(clientY < SIZE + 24);
|
||||
|
||||
$effect(() => {
|
||||
if (!loupeCanvas) return;
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
loupeCanvas.width = SIZE * dpr;
|
||||
loupeCanvas.height = SIZE * dpr;
|
||||
const ctx = loupeCanvas.getContext('2d');
|
||||
const src = ensureSource();
|
||||
if (!ctx || !src) return;
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
ctx.imageSmoothingEnabled = false;
|
||||
ctx.fillStyle = '#111318';
|
||||
ctx.fillRect(0, 0, SIZE, SIZE);
|
||||
ctx.drawImage(
|
||||
src,
|
||||
blockX,
|
||||
blockY,
|
||||
BLOCK,
|
||||
BLOCK,
|
||||
CENTER + (blockX - px) * ZOOM,
|
||||
CENTER + (blockY - py) * ZOOM,
|
||||
BLOCK * ZOOM,
|
||||
BLOCK * ZOOM
|
||||
);
|
||||
ctx.strokeStyle = 'rgba(255,255,255,0.9)';
|
||||
ctx.lineWidth = 1;
|
||||
ctx.strokeRect(CENTER - ZOOM / 2 + 0.5, CENTER - ZOOM / 2 + 0.5, ZOOM, ZOOM);
|
||||
});
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="loupe"
|
||||
class:flip={flipBelow}
|
||||
style="left:{clientX}px;top:{clientY}px"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<canvas bind:this={loupeCanvas} style="width:{SIZE}px;height:{SIZE}px"></canvas>
|
||||
<p class="hex">{hex}</p>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.loupe {
|
||||
position: fixed;
|
||||
z-index: 50;
|
||||
transform: translate(-50%, calc(-100% - 16px));
|
||||
padding: 4px 4px 2px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-m);
|
||||
box-shadow: var(--shadow-card);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.loupe.flip {
|
||||
transform: translate(-50%, 16px);
|
||||
}
|
||||
|
||||
canvas {
|
||||
display: block;
|
||||
border-radius: var(--radius-s);
|
||||
}
|
||||
|
||||
.hex {
|
||||
margin: 2px 0 0;
|
||||
text-align: center;
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,127 @@
|
||||
<script lang="ts">
|
||||
import CheckboxField from '../ui/CheckboxField.svelte';
|
||||
import DownloadButton from '../DownloadButton.svelte';
|
||||
import EmptyState from '../ui/EmptyState.svelte';
|
||||
import InfoPanel from '../InfoPanel.svelte';
|
||||
import Preview from '../Preview.svelte';
|
||||
import TextResult from './TextResult.svelte';
|
||||
import type { ImageInfo } from '$lib/core/analyze';
|
||||
import type { PixelImage } from '$lib/core/types';
|
||||
import { outputOf, type ToolEntry } from '$lib/registry';
|
||||
|
||||
type Status = 'idle' | 'loaded' | 'processing' | 'error';
|
||||
|
||||
interface Props {
|
||||
tool: ToolEntry;
|
||||
sourceLoaded: boolean;
|
||||
status: Status;
|
||||
result: PixelImage | null;
|
||||
previewResult: PixelImage | null;
|
||||
showMask: boolean;
|
||||
info: ImageInfo | null;
|
||||
isInfo: boolean;
|
||||
params: Record<string, unknown>;
|
||||
textResult: string | null;
|
||||
onDownloadError: (e: unknown) => void;
|
||||
}
|
||||
|
||||
let {
|
||||
tool,
|
||||
sourceLoaded,
|
||||
status,
|
||||
result,
|
||||
previewResult,
|
||||
showMask = $bindable(false),
|
||||
info,
|
||||
isInfo,
|
||||
params,
|
||||
textResult,
|
||||
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
|
||||
title="Результат появится здесь"
|
||||
hint="Сначала загрузите исходное изображение слева"
|
||||
/>
|
||||
</div>
|
||||
{:else if !isInfo && status === 'processing' && !result}
|
||||
<div class="media">
|
||||
<EmptyState
|
||||
title="Обработка…"
|
||||
hint="Изображение обрабатывается, это займёт немного времени"
|
||||
/>
|
||||
</div>
|
||||
{:else if isInfo}
|
||||
<div class="media">
|
||||
{#if info}
|
||||
<InfoPanel {info} />
|
||||
{/if}
|
||||
</div>
|
||||
{:else if tool.resultType === 'text'}
|
||||
<div class="media">
|
||||
{#if textResult !== null}
|
||||
<TextResult text={textResult} filename={tool.id} />
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
{#if hasPreview}
|
||||
<CheckboxField id="show-mask" label="Показать маску" bind:checked={showMask} />
|
||||
{/if}
|
||||
<div class="media">
|
||||
<Preview image={shown} />
|
||||
{#if status === 'processing'}
|
||||
<span class="recalc" aria-live="polite">Пересчёт…</span>
|
||||
{/if}
|
||||
</div>
|
||||
<DownloadButton
|
||||
image={result}
|
||||
format={outputOf(tool)}
|
||||
baseName={tool.id}
|
||||
{params}
|
||||
onError={onDownloadError}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
min-width: 0;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin-bottom: var(--space-1);
|
||||
}
|
||||
|
||||
.media {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 16rem;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.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,55 @@
|
||||
<script lang="ts">
|
||||
import type { PixelImage } from '$lib/core/types';
|
||||
import DropOverlay from '../DropOverlay.svelte';
|
||||
import DropZone from '../DropZone.svelte';
|
||||
import Preview from '../Preview.svelte';
|
||||
import Button from '../ui/Button.svelte';
|
||||
|
||||
interface Props {
|
||||
source: PixelImage | null;
|
||||
onFile: (file: File) => void;
|
||||
onError: (message: string) => void;
|
||||
onReset: () => void;
|
||||
pipetteActive?: boolean;
|
||||
onPickColor?: (hex: string) => void;
|
||||
}
|
||||
|
||||
let { source, onFile, onError, onReset, pipetteActive = false, onPickColor }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="container">
|
||||
<h2 class="heading-section">Исходник</h2>
|
||||
{#if !source}
|
||||
<DropZone {onFile} {onError} />
|
||||
{:else}
|
||||
<DropOverlay {onFile} {onError}>
|
||||
<div class="media">
|
||||
<Preview image={source} pipetteActive={pipetteActive} onPickColor={onPickColor} />
|
||||
</div>
|
||||
<Button variant="secondary" onclick={onReset}>Заменить изображение</Button>
|
||||
</DropOverlay>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
min-width: 0;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin-bottom: var(--space-1);
|
||||
}
|
||||
|
||||
.media {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 16rem;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,57 @@
|
||||
<script lang="ts">
|
||||
import Button from '../ui/Button.svelte';
|
||||
|
||||
interface Props {
|
||||
onSubmit: (text: string) => void;
|
||||
}
|
||||
|
||||
let { onSubmit }: Props = $props();
|
||||
|
||||
let text = $state('');
|
||||
|
||||
function submit() {
|
||||
if (text.trim().length === 0) return;
|
||||
onSubmit(text);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="container">
|
||||
<h2 class="heading-section">Текст</h2>
|
||||
<textarea
|
||||
class="input"
|
||||
rows="8"
|
||||
bind:value={text}
|
||||
placeholder="Вставьте данные сюда"
|
||||
aria-label="Текстовые данные"
|
||||
></textarea>
|
||||
<Button variant="secondary" onclick={submit} disabled={text.trim().length === 0}>
|
||||
Декодировать
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
min-width: 0;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin-bottom: var(--space-1);
|
||||
}
|
||||
|
||||
.input {
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
resize: vertical;
|
||||
padding: var(--space-2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-s);
|
||||
background: var(--surface);
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-s);
|
||||
min-height: 12rem;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,88 @@
|
||||
<script lang="ts">
|
||||
import { downloadBlob } from '$lib/core/io';
|
||||
|
||||
interface Props {
|
||||
text: string;
|
||||
filename: string;
|
||||
}
|
||||
|
||||
let { text, filename }: Props = $props();
|
||||
|
||||
let copied = $state(false);
|
||||
|
||||
async function copy() {
|
||||
await navigator.clipboard.writeText(text);
|
||||
copied = true;
|
||||
setTimeout(() => (copied = false), 1500);
|
||||
}
|
||||
|
||||
function download() {
|
||||
downloadBlob(new Blob([text], { type: 'text/plain' }), `${filename}.txt`);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="panel text-result">
|
||||
<textarea class="output" rows="10" readonly value={text} aria-label="Текстовый результат"></textarea>
|
||||
<div class="actions">
|
||||
<button type="button" class="secondary" onclick={copy}>
|
||||
{copied ? 'Скопировано' : 'Копировать'}
|
||||
</button>
|
||||
<button type="button" class="primary" onclick={download}>Скачать .txt</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.text-result {
|
||||
padding: var(--space-3);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.output {
|
||||
width: 100%;
|
||||
resize: vertical;
|
||||
padding: var(--space-2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-s);
|
||||
background: var(--bg);
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-xs);
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.actions button {
|
||||
flex: 1;
|
||||
padding: var(--space-2) var(--space-3);
|
||||
border-radius: var(--radius-s);
|
||||
border: 1px solid transparent;
|
||||
font-weight: 600;
|
||||
font-size: var(--text-m);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.actions .primary {
|
||||
background: var(--accent);
|
||||
color: var(--accent-contrast);
|
||||
}
|
||||
|
||||
.actions .primary:hover {
|
||||
background: var(--accent-hover);
|
||||
}
|
||||
|
||||
.actions .secondary {
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
border-color: var(--border);
|
||||
}
|
||||
|
||||
.actions .secondary:hover {
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,83 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
variant?: 'primary' | 'secondary';
|
||||
type?: 'button' | 'submit';
|
||||
disabled?: boolean;
|
||||
busy?: boolean;
|
||||
busyText?: string;
|
||||
fullWidth?: boolean;
|
||||
onclick?: () => void;
|
||||
children: Snippet;
|
||||
}
|
||||
|
||||
let {
|
||||
variant = 'primary',
|
||||
type = 'button',
|
||||
disabled = false,
|
||||
busy = false,
|
||||
busyText = '',
|
||||
fullWidth = false,
|
||||
onclick,
|
||||
children
|
||||
}: Props = $props();
|
||||
</script>
|
||||
|
||||
<button
|
||||
{type}
|
||||
class={fullWidth ? `${variant} fullwidth` : variant}
|
||||
aria-busy={busy}
|
||||
disabled={disabled || busy}
|
||||
onclick={onclick}
|
||||
>
|
||||
{#if busy && busyText}
|
||||
{busyText}
|
||||
{:else}
|
||||
{@render children()}
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
<style>
|
||||
button {
|
||||
padding: var(--space-2) var(--space-3);
|
||||
border-radius: var(--radius-s);
|
||||
border: 1px solid transparent;
|
||||
font-weight: 600;
|
||||
font-size: var(--text-m);
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background var(--transition-fast),
|
||||
border-color var(--transition-fast),
|
||||
color var(--transition-fast);
|
||||
}
|
||||
|
||||
button.primary {
|
||||
background: var(--accent);
|
||||
color: var(--accent-contrast);
|
||||
}
|
||||
|
||||
button.primary:hover:not(:disabled) {
|
||||
background: var(--accent-hover);
|
||||
}
|
||||
|
||||
button.secondary {
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
border-color: var(--border);
|
||||
}
|
||||
|
||||
button.secondary:hover:not(:disabled) {
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.fullwidth {
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,42 @@
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
id: string;
|
||||
label: string;
|
||||
checked?: boolean;
|
||||
hint?: string;
|
||||
}
|
||||
|
||||
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>
|
||||
{#if hint}
|
||||
<p class="hint text-caption text-muted">{hint}</p>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.checkbox {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
margin-bottom: var(--space-3);
|
||||
}
|
||||
|
||||
input[type='checkbox'] {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
label {
|
||||
font-size: var(--text-m);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.hint {
|
||||
margin: calc(-1 * var(--space-3)) 0 var(--space-3);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,80 @@
|
||||
<script lang="ts">
|
||||
import Field from './Field.svelte';
|
||||
|
||||
interface Props {
|
||||
id: string;
|
||||
label: string;
|
||||
value?: string;
|
||||
hint?: string;
|
||||
pipetteActive?: boolean;
|
||||
onPipetteToggle?: () => void;
|
||||
}
|
||||
|
||||
let { id, label, value = $bindable('#000000'), hint, pipetteActive = false, onPipetteToggle }:
|
||||
Props = $props();
|
||||
</script>
|
||||
|
||||
<Field {id} {label} {hint}>
|
||||
<div class="row">
|
||||
<input id={id} class="control swatch" type="color" bind:value />
|
||||
{#if onPipetteToggle}
|
||||
<button
|
||||
type="button"
|
||||
class="pipette"
|
||||
aria-label="Пипетка"
|
||||
aria-pressed={pipetteActive}
|
||||
title="Пипетка"
|
||||
onclick={onPipetteToggle}
|
||||
>
|
||||
◎
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</Field>
|
||||
|
||||
<style>
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.swatch {
|
||||
height: var(--control-height);
|
||||
width: 3.5rem;
|
||||
padding: 2px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.pipette {
|
||||
flex: none;
|
||||
width: var(--control-height);
|
||||
height: var(--control-height);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-s);
|
||||
background: var(--surface);
|
||||
color: var(--text-muted);
|
||||
font-size: 1.1rem;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
border-color var(--transition-fast),
|
||||
color var(--transition-fast),
|
||||
background var(--transition-fast);
|
||||
}
|
||||
|
||||
.pipette:hover {
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.pipette[aria-pressed='true'] {
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
background: color-mix(in srgb, var(--accent) 10%, var(--surface));
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,38 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
hint?: string;
|
||||
children?: Snippet;
|
||||
}
|
||||
|
||||
let { title, hint, children }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="empty panel">
|
||||
<p class="title">{title}</p>
|
||||
{#if hint}
|
||||
<p class="text-caption text-muted">{hint}</p>
|
||||
{/if}
|
||||
{#if children}
|
||||
{@render children()}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--space-1);
|
||||
min-height: 8rem;
|
||||
padding: var(--space-4);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-weight: 600;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,29 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
id: string;
|
||||
label: string;
|
||||
hint?: string;
|
||||
children: Snippet;
|
||||
}
|
||||
|
||||
let { id, label, hint, children }: Props = $props();
|
||||
</script>
|
||||
|
||||
<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>
|
||||
|
||||
<style>
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-1);
|
||||
margin-bottom: var(--space-3);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,21 @@
|
||||
<script lang="ts">
|
||||
import Field from './Field.svelte';
|
||||
|
||||
interface Props {
|
||||
id: string;
|
||||
label: string;
|
||||
value?: string;
|
||||
options: { value: string; label: string }[];
|
||||
hint?: string;
|
||||
}
|
||||
|
||||
let { id, label, value = $bindable(''), options, hint }: Props = $props();
|
||||
</script>
|
||||
|
||||
<Field {id} {label} {hint}>
|
||||
<select id={id} class="control" bind:value>
|
||||
{#each options as option (option.value)}
|
||||
<option value={option.value}>{option.label}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</Field>
|
||||
@@ -0,0 +1,112 @@
|
||||
<script lang="ts">
|
||||
import Field from './Field.svelte';
|
||||
|
||||
interface Props {
|
||||
id: string;
|
||||
label: string;
|
||||
value?: number;
|
||||
min?: number;
|
||||
max?: number;
|
||||
step?: number;
|
||||
default?: number;
|
||||
hint?: string;
|
||||
}
|
||||
|
||||
let {
|
||||
id,
|
||||
label,
|
||||
value = $bindable(0),
|
||||
min,
|
||||
max,
|
||||
step,
|
||||
default: defaultValue,
|
||||
hint
|
||||
}: Props = $props();
|
||||
|
||||
function decrement() {
|
||||
if (min !== undefined && value <= min) return;
|
||||
value = Math.max(min ?? -Infinity, value - (step ?? 1));
|
||||
}
|
||||
|
||||
function increment() {
|
||||
if (max !== undefined && value >= max) return;
|
||||
value = Math.min(max ?? Infinity, value + (step ?? 1));
|
||||
}
|
||||
|
||||
function reset() {
|
||||
if (defaultValue !== undefined) value = defaultValue;
|
||||
}
|
||||
|
||||
const resetDisabled = $derived(defaultValue === undefined || value === defaultValue);
|
||||
</script>
|
||||
|
||||
<Field {id} {label} {hint}>
|
||||
<div class="row">
|
||||
<button type="button" class="step" aria-label="Уменьшить" onclick={decrement}>−</button>
|
||||
<input id={id} type="range" min={min} max={max} step={step} bind:value />
|
||||
<button type="button" class="step" aria-label="Увеличить" onclick={increment}>+</button>
|
||||
<button
|
||||
type="button"
|
||||
class="step"
|
||||
aria-label="Сбросить"
|
||||
disabled={resetDisabled}
|
||||
onclick={reset}
|
||||
>
|
||||
↺
|
||||
</button>
|
||||
<output>{value}</output>
|
||||
</div>
|
||||
</Field>
|
||||
|
||||
<style>
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
max-width: var(--control-max-width);
|
||||
}
|
||||
|
||||
input[type='range'] {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
accent-color: var(--accent);
|
||||
}
|
||||
|
||||
.step {
|
||||
flex: none;
|
||||
width: 1.7rem;
|
||||
height: 1.7rem;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-s);
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
font-size: 0.9rem;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
border-color var(--transition-fast),
|
||||
color var(--transition-fast);
|
||||
}
|
||||
|
||||
.step:hover:not(:disabled) {
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.step:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
output {
|
||||
flex: none;
|
||||
min-width: 3ch;
|
||||
text-align: right;
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-s);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,20 @@
|
||||
<script lang="ts">
|
||||
import Field from './Field.svelte';
|
||||
|
||||
interface Props {
|
||||
id: string;
|
||||
label: string;
|
||||
value?: string | number;
|
||||
type?: 'number' | 'text';
|
||||
min?: number;
|
||||
max?: number;
|
||||
step?: number;
|
||||
hint?: string;
|
||||
}
|
||||
|
||||
let { id, label, value = $bindable(''), type = 'text', min, max, step, hint }: Props = $props();
|
||||
</script>
|
||||
|
||||
<Field {id} {label} {hint}>
|
||||
<input class="control" {id} {type} min={min} max={max} step={step} bind:value />
|
||||
</Field>
|
||||
@@ -0,0 +1,120 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { colorMask, flattenOntoColor, invertAlpha, parseHex, removeColorToAlpha } from './alpha';
|
||||
import { makeImage } from './test-helpers';
|
||||
|
||||
describe('invertAlpha', () => {
|
||||
it('обращает альфу, RGB не трогает', () => {
|
||||
const out = invertAlpha(makeImage(1, 1, [[10, 20, 30, 128]]));
|
||||
expect([...out.data]).toEqual([10, 20, 30, 127]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('removeColorToAlpha', () => {
|
||||
const fixture = () =>
|
||||
makeImage(2, 1, [
|
||||
[255, 255, 255, 255],
|
||||
[255, 0, 0, 255]
|
||||
]);
|
||||
|
||||
it('обнуляет альфу только у точного совпадения при tolerance=0', () => {
|
||||
const out = removeColorToAlpha(fixture(), '#ff0000', 0);
|
||||
expect(out.data[3]).toBe(255);
|
||||
expect(out.data[7]).toBe(0);
|
||||
expect(out.data[4]).toBe(255);
|
||||
expect(out.data[5]).toBe(0);
|
||||
});
|
||||
|
||||
it('поддерживает короткую форму и регистр hex', () => {
|
||||
expect([...removeColorToAlpha(fixture(), '#f00', 0).data.slice(4)]).toEqual([
|
||||
255, 0, 0, 0
|
||||
]);
|
||||
expect([...removeColorToAlpha(fixture(), '#FF0000', 0).data.slice(4)]).toEqual([
|
||||
255, 0, 0, 0
|
||||
]);
|
||||
});
|
||||
|
||||
it('tolerance 100% удаляет весь диапазон расстояний', () => {
|
||||
const out = removeColorToAlpha(fixture(), '#000000', 100);
|
||||
expect(out.data[3]).toBe(0);
|
||||
expect(out.data[7]).toBe(0);
|
||||
});
|
||||
|
||||
it('промежуточный tolerance различает близкие и далёкие цвета', () => {
|
||||
const img = makeImage(2, 1, [
|
||||
[0, 0, 0, 255],
|
||||
[128, 128, 128, 255]
|
||||
]);
|
||||
const kept = removeColorToAlpha(img, '#000000', 40);
|
||||
const removed = removeColorToAlpha(img, '#000000', 60);
|
||||
expect(kept.data[3]).toBe(0);
|
||||
expect(kept.data[7]).toBe(255);
|
||||
expect(removed.data[3]).toBe(0);
|
||||
expect(removed.data[7]).toBe(0);
|
||||
});
|
||||
|
||||
it('не мутирует вход', () => {
|
||||
const img = fixture();
|
||||
removeColorToAlpha(img, '#ffffff', 100);
|
||||
expect([...img.data]).toEqual([
|
||||
255, 255, 255, 255,
|
||||
255, 0, 0, 255
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('colorMask', () => {
|
||||
it('удаляемые пиксели белые, остальные чёрные, маска непрозрачная', () => {
|
||||
const out = colorMask(
|
||||
makeImage(2, 1, [
|
||||
[255, 0, 0, 255],
|
||||
[0, 255, 0, 255]
|
||||
]),
|
||||
'#ff0000',
|
||||
0
|
||||
);
|
||||
expect([...out.data]).toEqual([
|
||||
255, 255, 255, 255,
|
||||
0, 0, 0, 255
|
||||
]);
|
||||
});
|
||||
|
||||
it('порог совпадает с removeColorToAlpha', () => {
|
||||
const img = makeImage(2, 1, [
|
||||
[0, 0, 0, 255],
|
||||
[128, 128, 128, 255]
|
||||
]);
|
||||
const kept = colorMask(img, '#000000', 40);
|
||||
const removed = colorMask(img, '#000000', 60);
|
||||
expect(kept.data[4]).toBe(0);
|
||||
expect(removed.data[4]).toBe(255);
|
||||
});
|
||||
});
|
||||
|
||||
describe('flattenOntoColor', () => {
|
||||
it('непрозрачный пиксель не меняется, альфа становится 255', () => {
|
||||
const out = flattenOntoColor(makeImage(1, 1, [[10, 20, 30, 255]]), '#ffffff');
|
||||
expect([...out.data]).toEqual([10, 20, 30, 255]);
|
||||
});
|
||||
|
||||
it('полностью прозрачный пиксель становится цветом подложки', () => {
|
||||
const out = flattenOntoColor(makeImage(1, 1, [[99, 99, 99, 0]]), '#ff8040');
|
||||
expect([...out.data]).toEqual([255, 128, 64, 255]);
|
||||
});
|
||||
|
||||
it('полупрозрачный пиксель смешивается с подложкой', () => {
|
||||
const out = flattenOntoColor(makeImage(1, 1, [[10, 20, 30, 128]]), '#ffffff');
|
||||
expect([...out.data]).toEqual([132, 137, 142, 255]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseHex', () => {
|
||||
it('разбирает #rrggbb, rrggbb, #rgb', () => {
|
||||
expect(parseHex('#ff8040')).toEqual([255, 128, 64]);
|
||||
expect(parseHex('ff8040')).toEqual([255, 128, 64]);
|
||||
expect(parseHex('#F80')).toEqual([255, 136, 0]);
|
||||
});
|
||||
|
||||
it.each(['zzz', '12345', '##ff', ''])('бросает ошибку на "%s"', (bad) => {
|
||||
expect(() => parseHex(bad)).toThrow(/Некорректный HEX/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,90 @@
|
||||
import { createPixelImage, type PixelImage } from './types';
|
||||
|
||||
const MAX_COLOR_DISTANCE = Math.sqrt(3 * 255 * 255);
|
||||
|
||||
export function removeColorToAlpha(
|
||||
img: PixelImage,
|
||||
hex: string,
|
||||
tolerancePercent = 0
|
||||
): PixelImage {
|
||||
const [targetR, targetG, targetB] = parseHex(hex);
|
||||
const tolerance = (clamp(tolerancePercent, 0, 100) / 100) * MAX_COLOR_DISTANCE;
|
||||
const thresholdSq = tolerance * tolerance;
|
||||
const out: PixelImage = { width: img.width, height: img.height, data: img.data.slice() };
|
||||
for (let i = 0; i < out.data.length; i += 4) {
|
||||
const dr = out.data[i] - targetR;
|
||||
const dg = out.data[i + 1] - targetG;
|
||||
const db = out.data[i + 2] - targetB;
|
||||
if (dr * dr + dg * dg + db * db <= thresholdSq) {
|
||||
out.data[i + 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) {
|
||||
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] = 255 - img.data[i + 3];
|
||||
}
|
||||
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;
|
||||
const thresholdSq = tolerance * tolerance;
|
||||
const out = createPixelImage(img.width, img.height);
|
||||
for (let i = 0; i < out.data.length; i += 4) {
|
||||
const dr = img.data[i] - targetR;
|
||||
const dg = img.data[i + 1] - targetG;
|
||||
const db = img.data[i + 2] - targetB;
|
||||
const matched = dr * dr + dg * dg + db * db <= thresholdSq;
|
||||
out.data[i] = matched ? 255 : 0;
|
||||
out.data[i + 1] = matched ? 255 : 0;
|
||||
out.data[i + 2] = matched ? 255 : 0;
|
||||
out.data[i + 3] = 255;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function flattenOntoColor(img: PixelImage, hex: string): PixelImage {
|
||||
const [bgR, bgG, bgB] = parseHex(hex);
|
||||
const out = createPixelImage(img.width, img.height);
|
||||
for (let i = 0; i < out.data.length; i += 4) {
|
||||
const a = img.data[i + 3] / 255;
|
||||
const inv = 1 - a;
|
||||
out.data[i] = img.data[i] * a + bgR * inv;
|
||||
out.data[i + 1] = img.data[i + 1] * a + bgG * inv;
|
||||
out.data[i + 2] = img.data[i + 2] * a + bgB * inv;
|
||||
out.data[i + 3] = 255;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function parseHex(hex: string): [number, number, number] {
|
||||
const match = /^#?([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(hex.trim());
|
||||
if (!match) {
|
||||
throw new Error(`Некорректный HEX-цвет: "${hex}"`);
|
||||
}
|
||||
const digits = match[1];
|
||||
if (digits.length === 3) {
|
||||
return [
|
||||
parseInt(digits[0] + digits[0], 16),
|
||||
parseInt(digits[1] + digits[1], 16),
|
||||
parseInt(digits[2] + digits[2], 16)
|
||||
];
|
||||
}
|
||||
return [
|
||||
parseInt(digits.slice(0, 2), 16),
|
||||
parseInt(digits.slice(2, 4), 16),
|
||||
parseInt(digits.slice(4, 6), 16)
|
||||
];
|
||||
}
|
||||
|
||||
function clamp(value: number, min: number, max: number): number {
|
||||
return Math.min(max, Math.max(min, value));
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { imageInfo } from './analyze';
|
||||
import { makeImage } from './test-helpers';
|
||||
|
||||
describe('imageInfo', () => {
|
||||
it('находит полупрозрачные пиксели и считает уникальные RGBA-цвета', () => {
|
||||
const info = imageInfo(
|
||||
makeImage(2, 2, [
|
||||
[0, 0, 0, 255],
|
||||
[0, 0, 0, 255],
|
||||
[255, 0, 0, 128],
|
||||
[255, 0, 0, 128]
|
||||
])
|
||||
);
|
||||
expect(info).toEqual({ width: 2, height: 2, hasAlpha: true, colorCount: 2 });
|
||||
});
|
||||
|
||||
it('полностью непрозрачное изображение — hasAlpha false', () => {
|
||||
const info = imageInfo(
|
||||
makeImage(2, 1, [
|
||||
[10, 20, 30, 255],
|
||||
[40, 50, 60, 255]
|
||||
])
|
||||
);
|
||||
expect(info.hasAlpha).toBe(false);
|
||||
expect(info.colorCount).toBe(2);
|
||||
});
|
||||
|
||||
it('разная альфа означает разные цвета', () => {
|
||||
const info = imageInfo(
|
||||
makeImage(2, 1, [
|
||||
[255, 0, 0, 255],
|
||||
[255, 0, 0, 128]
|
||||
])
|
||||
);
|
||||
expect(info.hasAlpha).toBe(true);
|
||||
expect(info.colorCount).toBe(2);
|
||||
});
|
||||
|
||||
it('возвращает корректные размеры неквадрата', () => {
|
||||
const info = imageInfo(makeImage(5, 3, new Array(15).fill([1, 2, 3, 4])));
|
||||
expect(info.width).toBe(5);
|
||||
expect(info.height).toBe(3);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { PixelImage } from './types';
|
||||
|
||||
export type ImageInfo = {
|
||||
width: number;
|
||||
height: number;
|
||||
hasAlpha: boolean;
|
||||
colorCount: number;
|
||||
};
|
||||
|
||||
export function imageInfo(img: PixelImage): ImageInfo {
|
||||
let hasAlpha = false;
|
||||
const seen = new Set<number>();
|
||||
for (let i = 0; i < img.data.length; i += 4) {
|
||||
if (!hasAlpha && img.data[i + 3] !== 255) {
|
||||
hasAlpha = true;
|
||||
}
|
||||
const key =
|
||||
((img.data[i] << 24) |
|
||||
(img.data[i + 1] << 16) |
|
||||
(img.data[i + 2] << 8) |
|
||||
img.data[i + 3]) >>>
|
||||
0;
|
||||
seen.add(key);
|
||||
}
|
||||
return { width: img.width, height: img.height, hasAlpha, colorCount: seen.size };
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { encodeBmpBytes } from './bmp';
|
||||
import { makeImage } from './test-helpers';
|
||||
|
||||
describe('encodeBmpBytes', () => {
|
||||
it('пишет заголовки BM и размеры для 2x1 без паддинга-неопределённости', () => {
|
||||
const bytes = encodeBmpBytes(
|
||||
makeImage(2, 1, [
|
||||
[255, 0, 0, 255],
|
||||
[0, 255, 0, 255]
|
||||
])
|
||||
);
|
||||
expect([bytes[0], bytes[1]]).toEqual([0x42, 0x4d]);
|
||||
const view = new DataView(bytes.buffer);
|
||||
expect(view.getUint32(2, true)).toBe(54 + 8);
|
||||
expect(view.getInt32(18, true)).toBe(2);
|
||||
expect(view.getInt32(22, true)).toBe(1);
|
||||
expect(view.getUint16(28, true)).toBe(24);
|
||||
});
|
||||
|
||||
it('хранит пиксели в BGR снизу-вверх с паддингом строки', () => {
|
||||
const bytes = encodeBmpBytes(
|
||||
makeImage(2, 1, [
|
||||
[255, 0, 0, 255],
|
||||
[0, 255, 0, 255]
|
||||
])
|
||||
);
|
||||
expect([...bytes.slice(54, 60)]).toEqual([
|
||||
0, 0, 255,
|
||||
0, 255, 0
|
||||
]);
|
||||
expect(bytes[60]).toBe(0);
|
||||
expect(bytes[61]).toBe(0);
|
||||
});
|
||||
|
||||
it('нижняя строка изображения идёт первой в файле', () => {
|
||||
const bytes = encodeBmpBytes(
|
||||
makeImage(4, 2, [
|
||||
[10, 10, 10, 255],
|
||||
[20, 20, 20, 255],
|
||||
[30, 30, 30, 255],
|
||||
[40, 40, 40, 255],
|
||||
[50, 50, 50, 255],
|
||||
[60, 60, 60, 255],
|
||||
[70, 70, 70, 255],
|
||||
[80, 80, 80, 255]
|
||||
])
|
||||
);
|
||||
expect(bytes[54]).toBe(50);
|
||||
expect(bytes[54 + 12]).toBe(10);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
import type { PixelImage } from './types';
|
||||
|
||||
export function encodeBmpBytes(img: PixelImage): Uint8Array<ArrayBuffer> {
|
||||
const rowSize = Math.ceil((img.width * 3) / 4) * 4;
|
||||
const pixelArraySize = rowSize * img.height;
|
||||
const fileSize = 54 + pixelArraySize;
|
||||
|
||||
const buf = new Uint8Array(fileSize);
|
||||
const view = new DataView(buf.buffer);
|
||||
|
||||
buf[0] = 0x42;
|
||||
buf[1] = 0x4d;
|
||||
view.setUint32(2, fileSize, true);
|
||||
view.setUint32(10, 54, true);
|
||||
view.setUint32(14, 40, true);
|
||||
view.setInt32(18, img.width, true);
|
||||
view.setInt32(22, img.height, true);
|
||||
view.setUint16(26, 1, true);
|
||||
view.setUint16(28, 24, true);
|
||||
view.setUint32(30, 0, true);
|
||||
view.setUint32(34, pixelArraySize, true);
|
||||
view.setInt32(38, 2835, true);
|
||||
view.setInt32(42, 2835, true);
|
||||
|
||||
for (let y = 0; y < img.height; y++) {
|
||||
const srcY = img.height - 1 - y;
|
||||
let off = 54 + y * rowSize;
|
||||
for (let x = 0; x < img.width; x++) {
|
||||
const i = (srcY * img.width + x) * 4;
|
||||
buf[off++] = img.data[i + 2];
|
||||
buf[off++] = img.data[i + 1];
|
||||
buf[off++] = img.data[i];
|
||||
}
|
||||
}
|
||||
|
||||
return buf;
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
brightnessContrast,
|
||||
changeHue,
|
||||
extractChannel,
|
||||
grayscale,
|
||||
invert,
|
||||
posterize,
|
||||
rgbToHex,
|
||||
sepia,
|
||||
setOpacity,
|
||||
swapChannels,
|
||||
thresholdBlackWhite,
|
||||
twoColors
|
||||
} from './color';
|
||||
import { makeImage } from './test-helpers';
|
||||
|
||||
describe('rgbToHex', () => {
|
||||
it('форматирует базовые цвета', () => {
|
||||
expect(rgbToHex(255, 0, 0)).toBe('#ff0000');
|
||||
expect(rgbToHex(1, 2, 3)).toBe('#010203');
|
||||
});
|
||||
|
||||
it('округляет дробные значения и клампит диапазон', () => {
|
||||
expect(rgbToHex(127.6, -5, 300)).toBe('#8000ff');
|
||||
});
|
||||
});
|
||||
|
||||
describe('setOpacity', () => {
|
||||
it('умножает альфу на процент, RGB не трогает', () => {
|
||||
const out = setOpacity(makeImage(1, 1, [[10, 20, 30, 128]]), 50);
|
||||
expect([...out.data]).toEqual([10, 20, 30, 64]);
|
||||
});
|
||||
|
||||
it('100% не меняет, 0% делает полностью прозрачным', () => {
|
||||
expect(setOpacity(makeImage(1, 1, [[1, 2, 3, 200]]), 100).data[3]).toBe(200);
|
||||
expect(setOpacity(makeImage(1, 1, [[1, 2, 3, 200]]), 0).data[3]).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('sepia', () => {
|
||||
it('применяет классическую матрицу с клампом', () => {
|
||||
const out = sepia(makeImage(2, 1, [
|
||||
[255, 0, 0, 255],
|
||||
[255, 255, 255, 255]
|
||||
]));
|
||||
const px = (n: number) => [...out.data.slice(n * 4, n * 4 + 4)];
|
||||
expect(px(0)).toEqual([100, 89, 69, 255]);
|
||||
expect(px(1)[0]).toBe(255);
|
||||
expect(out.data[7]).toBe(255);
|
||||
});
|
||||
});
|
||||
|
||||
describe('changeHue', () => {
|
||||
it('чистый красный при +120° становится чистым зелёным', () => {
|
||||
const out = changeHue(makeImage(1, 1, [[255, 0, 0, 255]]), 120);
|
||||
expect([...out.data]).toEqual([0, 255, 0, 255]);
|
||||
});
|
||||
|
||||
it('сдвиг 360° возвращает исходные цвета', () => {
|
||||
const img = makeImage(1, 1, [[90, 140, 210, 255]]);
|
||||
expect([...changeHue(img, 360).data]).toEqual([...img.data]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractChannel', () => {
|
||||
it('выдаёт выбранный канал оттенками серого', () => {
|
||||
const out = extractChannel(makeImage(1, 1, [[10, 20, 30, 40]]), 'green');
|
||||
expect([...out.data]).toEqual([20, 20, 20, 40]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('swapChannels', () => {
|
||||
it('переставляет каналы парами', () => {
|
||||
expect([...swapChannels(makeImage(1, 1, [[10, 20, 30, 40]]), 'r-b').data]).toEqual([
|
||||
30, 20, 10, 40
|
||||
]);
|
||||
expect([...swapChannels(makeImage(1, 1, [[10, 20, 30, 40]]), 'g-b').data]).toEqual([
|
||||
10, 30, 20, 40
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('thresholdBlackWhite', () => {
|
||||
it('серый 128 относительно порога 50% — белый', () => {
|
||||
expect(thresholdBlackWhite(makeImage(1, 1, [[128, 128, 128, 255]]), 50).data[0]).toBe(255);
|
||||
expect(thresholdBlackWhite(makeImage(1, 1, [[128, 128, 128, 255]]), 60).data[0]).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('posterize', () => {
|
||||
it('два уровня квантуют в чёрное и белое', () => {
|
||||
const out = posterize(
|
||||
makeImage(2, 1, [
|
||||
[100, 100, 100, 255],
|
||||
[200, 200, 200, 255]
|
||||
]),
|
||||
2
|
||||
);
|
||||
expect(out.data[0]).toBe(0);
|
||||
expect(out.data[4]).toBe(255);
|
||||
});
|
||||
});
|
||||
|
||||
describe('twoColors', () => {
|
||||
it('яркие пиксели получают светлый цвет, тёмные — тёмный', () => {
|
||||
const out = twoColors(
|
||||
makeImage(2, 1, [
|
||||
[250, 250, 250, 255],
|
||||
[10, 10, 10, 128]
|
||||
]),
|
||||
'#ff0000',
|
||||
'#00ff00',
|
||||
50
|
||||
);
|
||||
expect([...out.data.slice(0, 4)]).toEqual([255, 0, 0, 255]);
|
||||
expect([...out.data.slice(4, 8)]).toEqual([0, 255, 0, 128]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('grayscale', () => {
|
||||
it('считает luma по весам BT.601 с округлением', () => {
|
||||
const out = grayscale(
|
||||
makeImage(3, 1, [
|
||||
[255, 0, 0, 255],
|
||||
[0, 255, 0, 255],
|
||||
[0, 0, 255, 255]
|
||||
])
|
||||
);
|
||||
const rgb = [...out.data].reduce<number[]>((acc, v, i) => {
|
||||
if (i % 4 === 0) acc.push(v);
|
||||
return acc;
|
||||
}, []);
|
||||
expect(rgb).toEqual([76, 150, 29]);
|
||||
});
|
||||
|
||||
it('сохраняет альфу и не мутирует вход', () => {
|
||||
const img = makeImage(1, 1, [[10, 20, 30, 200]]);
|
||||
const out = grayscale(img);
|
||||
expect([...out.data]).toEqual([18, 18, 18, 200]);
|
||||
expect([...img.data]).toEqual([10, 20, 30, 200]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('invert', () => {
|
||||
it('инвертирует RGB, не трогая альфу', () => {
|
||||
const out = invert(makeImage(1, 1, [[10, 200, 30, 7]]));
|
||||
expect([...out.data]).toEqual([245, 55, 225, 7]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('brightnessContrast', () => {
|
||||
const pixel = (r: number) => makeImage(1, 1, [[r, r, r, 255]]);
|
||||
const red = (out: number[]) => [out[0], out[1], out[2]];
|
||||
|
||||
it('b=0, c=0 — тождественное преобразование', () => {
|
||||
const out = brightnessContrast(pixel(77), 0, 0);
|
||||
expect(red([...out.data])).toEqual([77, 77, 77]);
|
||||
});
|
||||
|
||||
it('brightness +100 насыщает всё в белый', () => {
|
||||
const out = brightnessContrast(pixel(10), 100, 0);
|
||||
expect(red([...out.data])).toEqual([255, 255, 255]);
|
||||
});
|
||||
|
||||
it('brightness -100 заливает чёрным', () => {
|
||||
const out = brightnessContrast(pixel(240), -100, 0);
|
||||
expect(red([...out.data])).toEqual([0, 0, 0]);
|
||||
});
|
||||
|
||||
it('contrast -100 сводит всё к серому 128', () => {
|
||||
const out = brightnessContrast(pixel(30), 0, -100);
|
||||
expect(red([...out.data])).toEqual([128, 128, 128]);
|
||||
});
|
||||
|
||||
it('параметры вне диапазона клампятся', () => {
|
||||
const out = brightnessContrast(pixel(10), 150, 0);
|
||||
expect(red([...out.data])).toEqual([255, 255, 255]);
|
||||
});
|
||||
|
||||
it('альфа не меняется', () => {
|
||||
const out = brightnessContrast(makeImage(1, 1, [[10, 20, 30, 64]]), 50, 50);
|
||||
expect(out.data[3]).toBe(64);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,225 @@
|
||||
import { clonePixelImage, createPixelImage, type PixelImage } from './types';
|
||||
|
||||
export type RgbChannel = 'red' | 'green' | 'blue';
|
||||
|
||||
export type ChannelSwapPair = 'r-g' | 'r-b' | 'g-b';
|
||||
|
||||
export function setOpacity(img: PixelImage, percent: number): PixelImage {
|
||||
const factor = clamp(percent, 0, 100) / 100;
|
||||
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] * factor;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function sepia(img: PixelImage): PixelImage {
|
||||
const out = createPixelImage(img.width, img.height);
|
||||
for (let i = 0; i < out.data.length; i += 4) {
|
||||
const r = img.data[i];
|
||||
const g = img.data[i + 1];
|
||||
const b = img.data[i + 2];
|
||||
out.data[i] = Math.min(255, 0.393 * r + 0.769 * g + 0.189 * b);
|
||||
out.data[i + 1] = Math.min(255, 0.349 * r + 0.686 * g + 0.168 * b);
|
||||
out.data[i + 2] = Math.min(255, 0.272 * r + 0.534 * g + 0.131 * b);
|
||||
out.data[i + 3] = img.data[i + 3];
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function changeHue(img: PixelImage, degrees: number): PixelImage {
|
||||
const shift = (((Math.round(degrees) % 360) + 360) % 360) / 360;
|
||||
if (shift === 0) return clonePixelImage(img);
|
||||
const out = createPixelImage(img.width, img.height);
|
||||
for (let i = 0; i < out.data.length; i += 4) {
|
||||
const rf = img.data[i] / 255;
|
||||
const gf = img.data[i + 1] / 255;
|
||||
const bf = img.data[i + 2] / 255;
|
||||
const max = Math.max(rf, gf, bf);
|
||||
const min = Math.min(rf, gf, bf);
|
||||
const l = (max + min) / 2;
|
||||
let h = 0;
|
||||
let s = 0;
|
||||
if (max !== min) {
|
||||
const d = max - min;
|
||||
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
|
||||
if (max === rf) h = (gf - bf) / d + (gf < bf ? 6 : 0);
|
||||
else if (max === gf) h = (bf - rf) / d + 2;
|
||||
else h = (rf - gf) / d + 4;
|
||||
h /= 6;
|
||||
}
|
||||
h = (h + shift) % 1;
|
||||
const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
|
||||
const p = 2 * l - q;
|
||||
out.data[i] = hueComponent(p, q, h + 1 / 3) * 255;
|
||||
out.data[i + 1] = hueComponent(p, q, h) * 255;
|
||||
out.data[i + 2] = hueComponent(p, q, h - 1 / 3) * 255;
|
||||
out.data[i + 3] = img.data[i + 3];
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function hueComponent(p: number, q: number, t: number): number {
|
||||
if (t < 0) t += 1;
|
||||
if (t > 1) t -= 1;
|
||||
if (t < 1 / 6) return p + (q - p) * 6 * t;
|
||||
if (t < 1 / 2) return q;
|
||||
if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6;
|
||||
return p;
|
||||
}
|
||||
|
||||
const CHANNEL_INDEX: Record<RgbChannel, number> = { red: 0, green: 1, blue: 2 };
|
||||
|
||||
export function extractChannel(img: PixelImage, channel: RgbChannel): PixelImage {
|
||||
const index = CHANNEL_INDEX[channel];
|
||||
const out = createPixelImage(img.width, img.height);
|
||||
for (let i = 0; i < out.data.length; i += 4) {
|
||||
const v = img.data[i + index];
|
||||
out.data[i] = v;
|
||||
out.data[i + 1] = v;
|
||||
out.data[i + 2] = v;
|
||||
out.data[i + 3] = img.data[i + 3];
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const SWAP_INDEX: Record<ChannelSwapPair, [number, number]> = {
|
||||
'r-g': [0, 1],
|
||||
'r-b': [0, 2],
|
||||
'g-b': [1, 2]
|
||||
};
|
||||
|
||||
export function swapChannels(img: PixelImage, pair: ChannelSwapPair): PixelImage {
|
||||
const [a, b] = SWAP_INDEX[pair];
|
||||
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];
|
||||
const tmp = out.data[i + a];
|
||||
out.data[i + a] = out.data[i + b];
|
||||
out.data[i + b] = tmp;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function thresholdBlackWhite(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) {
|
||||
const luma = 0.299 * img.data[i] + 0.587 * img.data[i + 1] + 0.114 * img.data[i + 2];
|
||||
const v = luma >= threshold ? 255 : 0;
|
||||
out.data[i] = v;
|
||||
out.data[i + 1] = v;
|
||||
out.data[i + 2] = v;
|
||||
out.data[i + 3] = img.data[i + 3];
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function posterize(img: PixelImage, levels: number): PixelImage {
|
||||
const n = clamp(Math.round(levels), 2, 256);
|
||||
const stepSize = 255 / (n - 1);
|
||||
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] = Math.round(Math.round(img.data[i + ch] / stepSize) * stepSize);
|
||||
}
|
||||
out.data[i + 3] = img.data[i + 3];
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function twoColors(
|
||||
img: PixelImage,
|
||||
lightHex: string,
|
||||
darkHex: string,
|
||||
thresholdPercent: number
|
||||
): PixelImage {
|
||||
const [lr, lg, lb] = parseColor(lightHex);
|
||||
const [dr, dg, db] = parseColor(darkHex);
|
||||
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) {
|
||||
const luma = 0.299 * img.data[i] + 0.587 * img.data[i + 1] + 0.114 * img.data[i + 2];
|
||||
if (luma >= threshold) {
|
||||
out.data[i] = lr;
|
||||
out.data[i + 1] = lg;
|
||||
out.data[i + 2] = lb;
|
||||
} else {
|
||||
out.data[i] = dr;
|
||||
out.data[i + 1] = dg;
|
||||
out.data[i + 2] = db;
|
||||
}
|
||||
out.data[i + 3] = img.data[i + 3];
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function parseColor(hex: string): [number, number, number] {
|
||||
const match = /^#([0-9a-f]{6})$/i.exec(hex.trim());
|
||||
if (!match) {
|
||||
throw new Error(`Некорректный HEX-цвет: "${hex}"`);
|
||||
}
|
||||
const digits = match[1];
|
||||
return [
|
||||
parseInt(digits.slice(0, 2), 16),
|
||||
parseInt(digits.slice(2, 4), 16),
|
||||
parseInt(digits.slice(4, 6), 16)
|
||||
];
|
||||
}
|
||||
|
||||
export function grayscale(img: PixelImage): PixelImage {
|
||||
const out = createPixelImage(img.width, img.height);
|
||||
for (let i = 0; i < img.data.length; i += 4) {
|
||||
const luma = 0.299 * img.data[i] + 0.587 * img.data[i + 1] + 0.114 * img.data[i + 2];
|
||||
out.data[i] = luma;
|
||||
out.data[i + 1] = luma;
|
||||
out.data[i + 2] = luma;
|
||||
out.data[i + 3] = img.data[i + 3];
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function invert(img: PixelImage): PixelImage {
|
||||
const out = createPixelImage(img.width, img.height);
|
||||
for (let i = 0; i < img.data.length; i += 4) {
|
||||
out.data[i] = 255 - img.data[i];
|
||||
out.data[i + 1] = 255 - img.data[i + 1];
|
||||
out.data[i + 2] = 255 - img.data[i + 2];
|
||||
out.data[i + 3] = img.data[i + 3];
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function brightnessContrast(
|
||||
img: PixelImage,
|
||||
brightness: number,
|
||||
contrast: number
|
||||
): PixelImage {
|
||||
const offset = (clamp(brightness, -100, 100) / 100) * 255;
|
||||
const c = (clamp(contrast, -100, 100) / 100) * 255;
|
||||
const factor = (259 * (c + 255)) / (255 * (259 - c));
|
||||
const out = createPixelImage(img.width, img.height);
|
||||
for (let i = 0; i < img.data.length; i += 4) {
|
||||
for (let ch = 0; ch < 3; ch++) {
|
||||
const v = factor * (img.data[i + ch] + offset - 128) + 128;
|
||||
out.data[i + ch] = v;
|
||||
}
|
||||
out.data[i + 3] = img.data[i + 3];
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function rgbToHex(r: number, g: number, b: number): string {
|
||||
const byte = (v: number) => clamp(Math.round(v), 0, 255).toString(16).padStart(2, '0');
|
||||
return `#${byte(r)}${byte(g)}${byte(b)}`;
|
||||
}
|
||||
|
||||
function clamp(value: number, min: number, max: number): number {
|
||||
return Math.min(max, Math.max(min, value));
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { crop, flip, resize, rotate90 } from './geometry';
|
||||
import { makeImage } from './test-helpers';
|
||||
|
||||
const square = () =>
|
||||
makeImage(2, 2, [
|
||||
[1, 1, 1, 1],
|
||||
[2, 2, 2, 2],
|
||||
[3, 3, 3, 3],
|
||||
[4, 4, 4, 4]
|
||||
]);
|
||||
|
||||
describe('flip', () => {
|
||||
it('отражает по горизонтали (зеркало слева-направо)', () => {
|
||||
const out = flip(square(), 'horizontal');
|
||||
expect(out.width).toBe(2);
|
||||
expect(out.height).toBe(2);
|
||||
expect([...out.data]).toEqual([
|
||||
2, 2, 2, 2,
|
||||
1, 1, 1, 1,
|
||||
4, 4, 4, 4,
|
||||
3, 3, 3, 3
|
||||
]);
|
||||
});
|
||||
|
||||
it('отражает по вертикали (сверху-вниз)', () => {
|
||||
const out = flip(square(), 'vertical');
|
||||
expect([...out.data]).toEqual([
|
||||
3, 3, 3, 3,
|
||||
4, 4, 4, 4,
|
||||
1, 1, 1, 1,
|
||||
2, 2, 2, 2
|
||||
]);
|
||||
});
|
||||
|
||||
it('не мутирует вход', () => {
|
||||
const img = square();
|
||||
flip(img, 'horizontal');
|
||||
expect([...img.data]).toEqual([1, 1, 1, 1, 2, 2, 2, 2, 3, 3, 3, 3, 4, 4, 4, 4]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('rotate90', () => {
|
||||
it('поворачивает неквадрат 2x3 на 90° по часовой', () => {
|
||||
const rect = makeImage(2, 3, [
|
||||
[1, 1, 1, 1],
|
||||
[2, 2, 2, 2],
|
||||
[3, 3, 3, 3],
|
||||
[4, 4, 4, 4],
|
||||
[5, 5, 5, 5],
|
||||
[6, 6, 6, 6]
|
||||
]);
|
||||
const out = rotate90(rect, 1);
|
||||
expect(out.width).toBe(3);
|
||||
expect(out.height).toBe(2);
|
||||
expect([...out.data]).toEqual([
|
||||
5, 5, 5, 5,
|
||||
3, 3, 3, 3,
|
||||
1, 1, 1, 1,
|
||||
6, 6, 6, 6,
|
||||
4, 4, 4, 4,
|
||||
2, 2, 2, 2
|
||||
]);
|
||||
});
|
||||
|
||||
it('turns=0 возвращает копию без изменений', () => {
|
||||
const img = square();
|
||||
const out = rotate90(img, 0);
|
||||
expect(out).not.toBe(img);
|
||||
expect([...out.data]).toEqual([...img.data]);
|
||||
});
|
||||
|
||||
it('нормализует turns: 5 ≡ 1, -3 ≡ 1, 4 ≡ 0', () => {
|
||||
const once = [...rotate90(square(), 1).data];
|
||||
expect([...rotate90(square(), 5).data]).toEqual(once);
|
||||
expect([...rotate90(square(), -3).data]).toEqual(once);
|
||||
expect([...rotate90(square(), 4).data]).toEqual([...square().data]);
|
||||
});
|
||||
|
||||
it('turns=2 на квадрате равно двойному отражению', () => {
|
||||
const out = rotate90(square(), 2);
|
||||
expect([...out.data]).toEqual([
|
||||
4, 4, 4, 4,
|
||||
3, 3, 3, 3,
|
||||
2, 2, 2, 2,
|
||||
1, 1, 1, 1
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('crop', () => {
|
||||
const grid = () =>
|
||||
makeImage(3, 3, [
|
||||
[1, 1, 1, 1],
|
||||
[2, 2, 2, 2],
|
||||
[3, 3, 3, 3],
|
||||
[4, 4, 4, 4],
|
||||
[5, 5, 5, 5],
|
||||
[6, 6, 6, 6],
|
||||
[7, 7, 7, 7],
|
||||
[8, 8, 8, 8],
|
||||
[9, 9, 9, 9]
|
||||
]);
|
||||
|
||||
it('вырезает центральную область 2x2 из 3x3', () => {
|
||||
const out = crop(grid(), 1, 1, 2, 2);
|
||||
expect(out.width).toBe(2);
|
||||
expect(out.height).toBe(2);
|
||||
expect([...out.data]).toEqual([
|
||||
5, 5, 5, 5,
|
||||
6, 6, 6, 6,
|
||||
8, 8, 8, 8,
|
||||
9, 9, 9, 9
|
||||
]);
|
||||
});
|
||||
|
||||
it('усекает область, выходящую за границы', () => {
|
||||
const out = crop(grid(), -1, -1, 2, 2);
|
||||
expect(out.width).toBe(1);
|
||||
expect(out.height).toBe(1);
|
||||
expect([...out.data]).toEqual([1, 1, 1, 1]);
|
||||
});
|
||||
|
||||
it('бросает RangeError для области вне изображения', () => {
|
||||
expect(() => crop(grid(), 5, 5, 2, 2)).toThrow(RangeError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resize', () => {
|
||||
const twoByTwo = () =>
|
||||
makeImage(2, 2, [
|
||||
[0, 0, 0, 255],
|
||||
[100, 100, 100, 255],
|
||||
[200, 200, 200, 255],
|
||||
[255, 255, 255, 255]
|
||||
]);
|
||||
|
||||
it('совпадает с входом при тех же размерах', () => {
|
||||
const img = twoByTwo();
|
||||
expect([...resize(img, 2, 2).data]).toEqual([...img.data]);
|
||||
});
|
||||
|
||||
it('апскейл 2x2 -> 4x4 билинейно интерполирует', () => {
|
||||
const out = resize(twoByTwo(), 4, 4);
|
||||
expect(out.width).toBe(4);
|
||||
expect(out.height).toBe(4);
|
||||
const red = [...out.data].filter((_, i) => i % 4 === 0);
|
||||
expect(red.slice(0, 4)).toEqual([0, 25, 75, 100]);
|
||||
expect(red.slice(4, 8)).toEqual([50, 72, 117, 139]);
|
||||
});
|
||||
|
||||
it('бросает RangeError на некорректные размеры', () => {
|
||||
const img = twoByTwo();
|
||||
expect(() => resize(img, 0, 10)).toThrow(RangeError);
|
||||
expect(() => resize(img, 10.5, 10)).toThrow(RangeError);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,106 @@
|
||||
import { clonePixelImage, createPixelImage, type PixelImage } from './types';
|
||||
|
||||
export type FlipAxis = 'horizontal' | 'vertical';
|
||||
|
||||
export function flip(img: PixelImage, axis: FlipAxis): PixelImage {
|
||||
const out = createPixelImage(img.width, img.height);
|
||||
for (let y = 0; y < img.height; y++) {
|
||||
for (let x = 0; x < img.width; x++) {
|
||||
const sx = axis === 'horizontal' ? img.width - 1 - x : x;
|
||||
const sy = axis === 'vertical' ? img.height - 1 - y : y;
|
||||
copyPixel(img, sx, sy, out, x, y);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function rotate90(img: PixelImage, turns: number): PixelImage {
|
||||
const t = ((Math.trunc(turns) % 4) + 4) % 4;
|
||||
if (t === 0) return clonePixelImage(img);
|
||||
let current = img;
|
||||
for (let i = 0; i < t; i++) {
|
||||
current = rotateClockwise(current);
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
function rotateClockwise(img: PixelImage): PixelImage {
|
||||
const out = createPixelImage(img.height, img.width);
|
||||
for (let y = 0; y < img.height; y++) {
|
||||
for (let x = 0; x < img.width; x++) {
|
||||
copyPixel(img, x, y, out, img.height - 1 - y, x);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function crop(
|
||||
img: PixelImage,
|
||||
x: number,
|
||||
y: number,
|
||||
width: number,
|
||||
height: number
|
||||
): PixelImage {
|
||||
const sx = clampInt(x, 0, img.width);
|
||||
const sy = clampInt(y, 0, img.height);
|
||||
const ex = clampInt(x + width, sx, img.width);
|
||||
const ey = clampInt(y + height, sy, img.height);
|
||||
const w = ex - sx;
|
||||
const h = ey - sy;
|
||||
if (w <= 0 || h <= 0) {
|
||||
throw new RangeError('Область обрезки пуста: она целиком вне изображения');
|
||||
}
|
||||
const out = createPixelImage(w, h);
|
||||
for (let row = 0; row < h; row++) {
|
||||
const si = (sy + row) * img.width * 4 + sx * 4;
|
||||
out.data.set(img.data.subarray(si, si + w * 4), row * w * 4);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function resize(img: PixelImage, width: number, height: number): PixelImage {
|
||||
if (!Number.isInteger(width) || !Number.isInteger(height) || width < 1 || height < 1) {
|
||||
throw new RangeError('Размеры должны быть целыми числами >= 1');
|
||||
}
|
||||
const out = createPixelImage(width, height);
|
||||
const xr = img.width / width;
|
||||
const yr = img.height / height;
|
||||
const maxX = img.width - 1;
|
||||
const maxY = img.height - 1;
|
||||
for (let dy = 0; dy < height; dy++) {
|
||||
const fy = Math.min(Math.max((dy + 0.5) * yr - 0.5, 0), maxY);
|
||||
const y0 = Math.floor(fy);
|
||||
const ty = fy - y0;
|
||||
const y1 = Math.min(y0 + 1, maxY);
|
||||
for (let dx = 0; dx < width; dx++) {
|
||||
const fx = Math.min(Math.max((dx + 0.5) * xr - 0.5, 0), maxX);
|
||||
const x0 = Math.floor(fx);
|
||||
const tx = fx - x0;
|
||||
const x1 = Math.min(x0 + 1, maxX);
|
||||
const di = (dy * width + dx) * 4;
|
||||
for (let ch = 0; ch < 4; ch++) {
|
||||
const p00 = img.data[(y0 * img.width + x0) * 4 + ch];
|
||||
const p10 = img.data[(y0 * img.width + x1) * 4 + ch];
|
||||
const p01 = img.data[(y1 * img.width + x0) * 4 + ch];
|
||||
const p11 = img.data[(y1 * img.width + x1) * 4 + ch];
|
||||
const top = (1 - tx) * p00 + tx * p10;
|
||||
const bottom = (1 - tx) * p01 + tx * p11;
|
||||
out.data[di + ch] = (1 - ty) * top + ty * bottom;
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function copyPixel(src: PixelImage, sx: number, sy: number, dst: PixelImage, dx: number, dy: number): void {
|
||||
const si = (sy * src.width + sx) * 4;
|
||||
const di = (dy * dst.width + dx) * 4;
|
||||
dst.data[di] = src.data[si];
|
||||
dst.data[di + 1] = src.data[si + 1];
|
||||
dst.data[di + 2] = src.data[si + 2];
|
||||
dst.data[di + 3] = src.data[si + 3];
|
||||
}
|
||||
|
||||
function clampInt(value: number, min: number, max: number): number {
|
||||
return Math.min(max, Math.max(min, Math.trunc(value)));
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { isSupportedImage, unsupportedImageMessage } from './io';
|
||||
|
||||
describe('isSupportedImage', () => {
|
||||
it.each(['image/png', 'image/jpeg', 'image/webp', 'image/gif', 'image/bmp', 'image/x-icon'])(
|
||||
'принимает %s',
|
||||
(type) => {
|
||||
expect(isSupportedImage(new File([], 'x', { type }))).toBe(true);
|
||||
}
|
||||
);
|
||||
|
||||
it('отклоняет неподдерживаемый тип', () => {
|
||||
expect(isSupportedImage(new File([], 'a.txt', { type: 'text/plain' }))).toBe(false);
|
||||
});
|
||||
|
||||
it('отклоняет файл без типа', () => {
|
||||
expect(isSupportedImage(new File([], 'x'))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('unsupportedImageMessage', () => {
|
||||
it('упоминает тип файла и поддерживаемые форматы', () => {
|
||||
const message = unsupportedImageMessage(new File([], 'a.txt', { type: 'text/plain' }));
|
||||
expect(message).toContain('text/plain');
|
||||
expect(message).toContain('PNG');
|
||||
});
|
||||
|
||||
it('сообщает про неизвестный тип, когда он пуст', () => {
|
||||
expect(unsupportedImageMessage(new File([], 'x'))).toContain('неизвестный');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,125 @@
|
||||
import { encodeBmpBytes } from './bmp';
|
||||
import { type PixelImage } from './types';
|
||||
|
||||
export type OutputMime = 'image/png' | 'image/jpeg' | 'image/webp' | 'image/bmp';
|
||||
|
||||
export const ACCEPTED_IMAGE_TYPES =
|
||||
'image/png,image/jpeg,image/webp,image/gif,image/bmp,image/x-icon';
|
||||
|
||||
const SUPPORTED_MIME_TYPES = new Set(ACCEPTED_IMAGE_TYPES.split(','));
|
||||
|
||||
export function isSupportedImage(file: File): boolean {
|
||||
return SUPPORTED_MIME_TYPES.has(file.type);
|
||||
}
|
||||
|
||||
export function unsupportedImageMessage(file: File): string {
|
||||
return `Неподдерживаемый формат файла (${file.type || 'неизвестный'}). Поддерживаются PNG, JPEG, WebP, GIF и BMP.`;
|
||||
}
|
||||
|
||||
async function decodeBitmap(bitmap: ImageBitmap): Promise<PixelImage> {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = bitmap.width;
|
||||
canvas.height = bitmap.height;
|
||||
const ctx = canvas.getContext('2d', { willReadFrequently: true });
|
||||
if (!ctx) {
|
||||
throw new Error('Canvas 2D context недоступен в этом браузере');
|
||||
}
|
||||
ctx.drawImage(bitmap, 0, 0);
|
||||
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
|
||||
return { width: imageData.width, height: imageData.height, data: imageData.data };
|
||||
}
|
||||
|
||||
export async function decodeFile(file: File): Promise<PixelImage> {
|
||||
const bitmap = await createImageBitmap(file);
|
||||
try {
|
||||
return await decodeBitmap(bitmap);
|
||||
} finally {
|
||||
bitmap.close();
|
||||
}
|
||||
}
|
||||
|
||||
export async function decodeBytes(bytes: Uint8Array<ArrayBuffer>): Promise<PixelImage> {
|
||||
const blob = new Blob([bytes]);
|
||||
const bitmap = await createImageBitmap(blob);
|
||||
try {
|
||||
return await decodeBitmap(bitmap);
|
||||
} finally {
|
||||
bitmap.close();
|
||||
}
|
||||
}
|
||||
|
||||
export function toDataUrl(img: PixelImage): string {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = img.width;
|
||||
canvas.height = img.height;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) {
|
||||
throw new Error('Canvas 2D context недоступен в этом браузере');
|
||||
}
|
||||
ctx.putImageData(new ImageData(img.data, img.width, img.height), 0, 0);
|
||||
return canvas.toDataURL('image/png');
|
||||
}
|
||||
|
||||
export function toBase64(img: PixelImage): string {
|
||||
return toDataUrl(img).slice('data:image/png;base64,'.length);
|
||||
}
|
||||
|
||||
export async function decodeTextImage(text: string): Promise<PixelImage> {
|
||||
const cleaned = text.trim().replace(/^data:[^,]*,/, '');
|
||||
if (cleaned.length === 0) {
|
||||
throw new Error('Вставьте base64-строку или data-uri изображения');
|
||||
}
|
||||
const binary = atob(cleaned);
|
||||
const bytes = Uint8Array.from(binary, (ch) => ch.charCodeAt(0));
|
||||
return await decodeBytes(bytes);
|
||||
}
|
||||
|
||||
export async function encode(
|
||||
img: PixelImage,
|
||||
mime: OutputMime = 'image/png',
|
||||
quality?: number
|
||||
): Promise<Blob> {
|
||||
if (mime === 'image/bmp') {
|
||||
return new Blob([encodeBmpBytes(img)], { type: mime });
|
||||
}
|
||||
if (mime === 'image/jpeg' || mime === 'image/webp') {
|
||||
if (quality !== undefined && (quality < 0 || quality > 1)) {
|
||||
throw new RangeError('quality должен быть в диапазоне 0..1');
|
||||
}
|
||||
}
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = img.width;
|
||||
canvas.height = img.height;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) {
|
||||
throw new Error('Canvas 2D context недоступен в этом браузере');
|
||||
}
|
||||
ctx.putImageData(new ImageData(img.data, img.width, img.height), 0, 0);
|
||||
return await canvasToBlob(canvas, mime, quality);
|
||||
}
|
||||
|
||||
function canvasToBlob(canvas: HTMLCanvasElement, mime: OutputMime, quality?: number): Promise<Blob> {
|
||||
return new Promise((resolve, reject) => {
|
||||
canvas.toBlob(
|
||||
(blob) => (blob ? resolve(blob) : reject(new Error(`Браузер не поддерживает кодирование в ${mime}`))),
|
||||
mime,
|
||||
quality
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export function downloadBlob(blob: Blob, filename: string): void {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.append(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
export function replaceExtension(filename: string, ext: string): string {
|
||||
const base = filename.replace(/\.[^./\\]+$/, '');
|
||||
return `${base}.${ext}`;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { expect } from 'vitest';
|
||||
import type { PixelImage } from './types';
|
||||
|
||||
export function makeImage(width: number, height: number, pixels: number[][]): PixelImage {
|
||||
const data = new Uint8ClampedArray(pixels.flat());
|
||||
if (data.length !== width * height * 4) {
|
||||
throw new Error(`Fixture mismatch: ${data.length} байт на ${width}x${height}`);
|
||||
}
|
||||
return { width, height, data };
|
||||
}
|
||||
|
||||
export function expectImageEqual(actual: PixelImage, expectedPixels: number[][]): void {
|
||||
expect([...actual.data]).toEqual(expectedPixels.flat());
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { hexToPixels, pixelsToHex } from './text';
|
||||
import { makeImage } from './test-helpers';
|
||||
|
||||
describe('pixelsToHex', () => {
|
||||
it('форматирует пиксели как rrggbbaa построчно', () => {
|
||||
const out = pixelsToHex(
|
||||
makeImage(2, 2, [
|
||||
[255, 0, 0, 255],
|
||||
[0, 255, 0, 200],
|
||||
[16, 32, 48, 64],
|
||||
[0, 0, 0, 0]
|
||||
])
|
||||
);
|
||||
expect(out).toBe('ff0000ff 00ff00c8\n10203040 00000000');
|
||||
});
|
||||
});
|
||||
|
||||
describe('hexToPixels', () => {
|
||||
it('обратим к pixelsToHex', () => {
|
||||
const source = makeImage(3, 1, [
|
||||
[1, 2, 3, 4],
|
||||
[250, 251, 252, 253],
|
||||
[9, 9, 9, 128]
|
||||
]);
|
||||
expect(hexToPixels(pixelsToHex(source), 3)).toEqual(source);
|
||||
});
|
||||
|
||||
it('допускает произвольные переводы строк и регистр', () => {
|
||||
const out = hexToPixels('FF0000FF\n\n00FF0080 00000080', 1);
|
||||
expect(out.width).toBe(1);
|
||||
expect(out.height).toBe(3);
|
||||
expect([...out.data]).toEqual([
|
||||
255, 0, 0, 255,
|
||||
0, 255, 0, 128,
|
||||
0, 0, 0, 128
|
||||
]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['ff0000', 'битые токены'],
|
||||
['ff0000ff ff0000ff ff0000ff', 'не делится на ширину'],
|
||||
['', 'пустой ввод']
|
||||
])('бросает понятную ошибку: %s (%s)', (input) => {
|
||||
expect(() => hexToPixels(input, 2)).toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
import type { PixelImage } from './types';
|
||||
|
||||
export function pixelsToHex(img: PixelImage): string {
|
||||
const rows: string[] = [];
|
||||
for (let y = 0; y < img.height; y++) {
|
||||
const parts: string[] = [];
|
||||
for (let x = 0; x < img.width; x++) {
|
||||
const i = (y * img.width + x) * 4;
|
||||
parts.push(byteHex(img.data[i]) + byteHex(img.data[i + 1]) + byteHex(img.data[i + 2]) + byteHex(img.data[i + 3]));
|
||||
}
|
||||
rows.push(parts.join(' '));
|
||||
}
|
||||
return rows.join('\n');
|
||||
}
|
||||
|
||||
export function hexToPixels(text: string, width: number): PixelImage {
|
||||
if (!Number.isInteger(width) || width < 1) {
|
||||
throw new Error('Укажите ширину изображения (целое число >= 1)');
|
||||
}
|
||||
const tokens = text.trim().split(/\s+/).filter((t) => t.length > 0);
|
||||
if (tokens.length === 0) {
|
||||
throw new Error('Вставьте hex-данные пикселей');
|
||||
}
|
||||
if (tokens.some((t) => !/^[0-9a-fA-F]{8}$/.test(t))) {
|
||||
throw new Error('Каждый пиксель должен быть 8 hex-символов RRGGBBAA, разделённых пробелами');
|
||||
}
|
||||
const height = tokens.length / width;
|
||||
if (!Number.isInteger(height)) {
|
||||
throw new Error(
|
||||
`Число пикселей (${tokens.length}) не делится на ширину ${width} без остатка`
|
||||
);
|
||||
}
|
||||
const data = new Uint8ClampedArray(tokens.length * 4);
|
||||
tokens.forEach((token, index) => {
|
||||
data[index * 4] = parseInt(token.slice(0, 2), 16);
|
||||
data[index * 4 + 1] = parseInt(token.slice(2, 4), 16);
|
||||
data[index * 4 + 2] = parseInt(token.slice(4, 6), 16);
|
||||
data[index * 4 + 3] = parseInt(token.slice(6, 8), 16);
|
||||
});
|
||||
return { width, height, data };
|
||||
}
|
||||
|
||||
function byteHex(value: number): string {
|
||||
return value.toString(16).padStart(2, '0');
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
export type PixelImage = {
|
||||
width: number;
|
||||
height: number;
|
||||
data: Uint8ClampedArray<ArrayBuffer>;
|
||||
};
|
||||
|
||||
export function createPixelImage(width: number, height: number): PixelImage {
|
||||
return {
|
||||
width,
|
||||
height,
|
||||
data: new Uint8ClampedArray(width * height * 4)
|
||||
};
|
||||
}
|
||||
|
||||
export function clonePixelImage(img: PixelImage): PixelImage {
|
||||
return { width: img.width, height: img.height, data: img.data.slice() };
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
// place files you want to import through the `$lib` alias in this folder.
|
||||
@@ -0,0 +1,203 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { CATEGORIES } from './categories';
|
||||
import {
|
||||
defaultParams,
|
||||
getTool,
|
||||
outputOf,
|
||||
sanitizeParams,
|
||||
TOOLS,
|
||||
type ParamDef,
|
||||
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);
|
||||
for (const id of ids) {
|
||||
expect(id).toMatch(/^[a-z0-9]+(-[a-z0-9]+)*$/);
|
||||
}
|
||||
});
|
||||
|
||||
it.each(TOOLS.map((t) => [t.id, t] as const))('%s: категория валидна', (_, tool) => {
|
||||
expect(CATEGORIES.map((c) => c.id)).toContain(tool.category);
|
||||
});
|
||||
|
||||
it.each(TOOLS.map((t) => [t.id, t] as const))('%s: исполнители определены', (_, tool) => {
|
||||
if (tool.resultType === 'text') {
|
||||
expect(typeof tool.toText).toBe('function');
|
||||
} else {
|
||||
expect(typeof tool.run).toBe('function');
|
||||
}
|
||||
if (tool.preview) {
|
||||
expect(typeof tool.preview).toBe('function');
|
||||
}
|
||||
expect(tool.title.length).toBeGreaterThan(0);
|
||||
expect(tool.description.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('у select дефолт входит в options, у number/slider дефолт в диапазоне', () => {
|
||||
for (const tool of TOOLS) {
|
||||
for (const param of tool.params) {
|
||||
if (param.type === 'select') {
|
||||
expect(param.options.map((o) => o.value)).toContain(param.default);
|
||||
expect(param.options.length).toBeGreaterThan(0);
|
||||
}
|
||||
if (param.type === 'number') {
|
||||
expect(param.min === undefined || param.default >= param.min).toBe(true);
|
||||
expect(param.max === undefined || param.default <= param.max).toBe(true);
|
||||
}
|
||||
if (param.type === 'slider') {
|
||||
expect(param.min).toBeLessThan(param.max);
|
||||
expect(param.default).toBeGreaterThanOrEqual(param.min);
|
||||
expect(param.default).toBeLessThanOrEqual(param.max);
|
||||
}
|
||||
if (param.type === 'color') {
|
||||
expect(param.default).toMatch(/^#[0-9a-f]{6}$/i);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('id параметров уникальны внутри инструмента', () => {
|
||||
for (const tool of TOOLS) {
|
||||
const ids = tool.params.map((p) => p.id);
|
||||
expect(new Set(ids).size).toBe(ids.length);
|
||||
}
|
||||
});
|
||||
|
||||
it('qualityParamId ссылается на существующий числовой параметр', () => {
|
||||
for (const tool of TOOLS) {
|
||||
const output = outputOf(tool);
|
||||
if (output?.qualityParamId) {
|
||||
const param = tool.params.find(
|
||||
(p): p is Extract<ParamDef, { type: 'number' | 'slider' }> =>
|
||||
p.id === output.qualityParamId
|
||||
);
|
||||
expect(param).toBeDefined();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('png-info не имеет формата вывода, конвертеры имеют jpeg/webp, остальные — png', () => {
|
||||
expect(outputOf(getToolOrThrow('png-info'))).toBeUndefined();
|
||||
expect(outputOf(getToolOrThrow('convert-png-to-jpg'))?.mime).toBe('image/jpeg');
|
||||
expect(outputOf(getToolOrThrow('convert-png-to-webp'))?.mime).toBe('image/webp');
|
||||
expect(outputOf(getToolOrThrow('grayscale-png'))).toEqual({ mime: 'image/png', ext: 'png' });
|
||||
});
|
||||
|
||||
it('defaultParams собирает значения по умолчанию', () => {
|
||||
const resize = getToolOrThrow('resize-png');
|
||||
expect(defaultParams(resize)).toEqual({ width: 0, height: 0, keepAspect: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe('sanitizeParams', () => {
|
||||
it('заменяет пустые и невалидные числа на дефолт', () => {
|
||||
const resize = getToolOrThrow('resize-png');
|
||||
expect(sanitizeParams(resize, { width: undefined, height: null, keepAspect: true })).toEqual({
|
||||
width: 0,
|
||||
height: 0,
|
||||
keepAspect: true
|
||||
});
|
||||
});
|
||||
|
||||
it('клампит числа в диапазон параметра', () => {
|
||||
const bc = getToolOrThrow('adjust-brightness-contrast-png');
|
||||
expect(sanitizeParams(bc, { brightness: 5000, contrast: -999 })).toEqual({
|
||||
brightness: 100,
|
||||
contrast: -100
|
||||
});
|
||||
});
|
||||
|
||||
it('возвращает дефолт для невалидных select, checkbox и color', () => {
|
||||
const rotate = getToolOrThrow('rotate-png');
|
||||
expect(sanitizeParams(rotate, { angle: '45' })).toEqual({ angle: '90' });
|
||||
const resize = getToolOrThrow('resize-png');
|
||||
expect(sanitizeParams(resize, { width: 10, height: 10, keepAspect: 'yes' })).toEqual({
|
||||
width: 10,
|
||||
height: 10,
|
||||
keepAspect: true
|
||||
});
|
||||
const jpg = getToolOrThrow('convert-png-to-jpg');
|
||||
expect(sanitizeParams(jpg, { background: 'red', quality: 50 })).toEqual({
|
||||
background: '#ffffff',
|
||||
quality: 50
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('run инструмента resize-png', () => {
|
||||
const img = { width: 100, height: 50, data: new Uint8ClampedArray(100 * 50 * 4) };
|
||||
const runResize = async (params: Record<string, unknown>) =>
|
||||
getToolOrThrow('resize-png').run!(img, params);
|
||||
|
||||
it('keepAspect + одна сторона — вторая считается по пропорции', async () => {
|
||||
const out = await runResize({ width: 200, height: 0, keepAspect: true });
|
||||
expect(out.width).toBe(200);
|
||||
expect(out.height).toBe(100);
|
||||
});
|
||||
|
||||
it('keepAspect + обе стороны — вписывание в размеры без искажения', async () => {
|
||||
const out = await runResize({ width: 50, height: 50, keepAspect: true });
|
||||
expect(out.width).toBe(50);
|
||||
expect(out.height).toBe(25);
|
||||
});
|
||||
|
||||
it('без keepAspect — обе стороны как задано', async () => {
|
||||
const out = await runResize({ width: 30, height: 40, keepAspect: false });
|
||||
expect(out.width).toBe(30);
|
||||
expect(out.height).toBe(40);
|
||||
});
|
||||
|
||||
it('обе стороны 0 — человекочитаемая ошибка', async () => {
|
||||
await expect(runResize({ width: 0, height: 0, keepAspect: true })).rejects.toThrow(
|
||||
'Укажите ширину'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
function getToolOrThrow(id: string): ToolEntry {
|
||||
const tool = getTool(id);
|
||||
if (!tool) throw new Error(`Инструмент "${id}" не найден`);
|
||||
return tool;
|
||||
}
|
||||
@@ -0,0 +1,529 @@
|
||||
import type { CategoryId } from './categories';
|
||||
import { colorMask, flattenOntoColor, invertAlpha, removeColorToAlpha } from './core/alpha';
|
||||
import {
|
||||
brightnessContrast,
|
||||
changeHue,
|
||||
extractChannel,
|
||||
grayscale,
|
||||
invert,
|
||||
posterize,
|
||||
sepia,
|
||||
setOpacity,
|
||||
swapChannels,
|
||||
thresholdBlackWhite,
|
||||
twoColors,
|
||||
type ChannelSwapPair,
|
||||
type RgbChannel
|
||||
} from './core/color';
|
||||
import { crop, flip, resize, rotate90 } 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';
|
||||
|
||||
export type ParamDef =
|
||||
| {
|
||||
id: string;
|
||||
label: string;
|
||||
type: 'number';
|
||||
min?: number;
|
||||
max?: number;
|
||||
step?: number;
|
||||
default: number;
|
||||
}
|
||||
| {
|
||||
id: string;
|
||||
label: string;
|
||||
type: 'slider';
|
||||
min: number;
|
||||
max: number;
|
||||
step?: number;
|
||||
default: number;
|
||||
}
|
||||
| {
|
||||
id: string;
|
||||
label: string;
|
||||
type: 'select';
|
||||
options: { value: string; label: string }[];
|
||||
default: string;
|
||||
}
|
||||
| { id: string; label: string; type: 'checkbox'; default: boolean }
|
||||
| { id: string; label: string; type: 'color'; default: string };
|
||||
|
||||
export type OutputFormat = {
|
||||
mime: OutputMime;
|
||||
ext: string;
|
||||
qualityParamId?: string;
|
||||
};
|
||||
|
||||
export type SourceMode = 'file' | 'none' | 'text';
|
||||
|
||||
export type ToolEntry = {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
category: CategoryId;
|
||||
sourceMode?: SourceMode;
|
||||
params: ParamDef[];
|
||||
run?: (img: PixelImage, params: Record<string, unknown>) => Promise<PixelImage> | PixelImage;
|
||||
generate?: (params: Record<string, unknown>) => Promise<PixelImage> | PixelImage;
|
||||
toText?: (img: PixelImage, params: Record<string, unknown>) => Promise<string> | string;
|
||||
runFromText?: (text: string, params: Record<string, unknown>) => Promise<PixelImage> | PixelImage;
|
||||
preview?: (
|
||||
img: PixelImage,
|
||||
params: Record<string, unknown>
|
||||
) => Promise<PixelImage> | PixelImage;
|
||||
resultType?: 'image' | 'info' | 'text';
|
||||
output?: OutputFormat;
|
||||
};
|
||||
|
||||
const PNG_OUTPUT: OutputFormat = { mime: 'image/png', ext: 'png' };
|
||||
|
||||
function num(params: Record<string, unknown>, id: string): number {
|
||||
const v = params[id];
|
||||
if (typeof v !== 'number' || !Number.isFinite(v)) {
|
||||
throw new Error(`Параметр "${id}" должен быть числом`);
|
||||
}
|
||||
return v;
|
||||
}
|
||||
|
||||
function str(params: Record<string, unknown>, id: string): string {
|
||||
const v = params[id];
|
||||
if (typeof v !== 'string') {
|
||||
throw new Error(`Параметр "${id}" должен быть строкой`);
|
||||
}
|
||||
return v;
|
||||
}
|
||||
|
||||
function decodeToPng(id: string, title: string, description: string): ToolEntry {
|
||||
return {
|
||||
id,
|
||||
title,
|
||||
description,
|
||||
category: 'convert',
|
||||
params: [],
|
||||
run: (img) => clonePixelImage(img)
|
||||
};
|
||||
}
|
||||
|
||||
export const TOOLS: ToolEntry[] = [
|
||||
decodeToPng(
|
||||
'jpg-to-png',
|
||||
'Конвертировать JPG в PNG',
|
||||
'Открывает JPEG и сохраняет его как PNG без потерь. Прозрачность, если была, сохраняется.'
|
||||
),
|
||||
decodeToPng(
|
||||
'webp-to-png',
|
||||
'Конвертировать WebP в PNG',
|
||||
'Перекодирует WebP-изображение в универсальный PNG.'
|
||||
),
|
||||
decodeToPng(
|
||||
'gif-to-png',
|
||||
'Конвертировать GIF в PNG',
|
||||
'Dостаёт первый кадр GIF-анимации и сохраняет его как PNG.'
|
||||
),
|
||||
decodeToPng(
|
||||
'bmp-to-png',
|
||||
'Конвертировать BMP в PNG',
|
||||
'Перекодирует BMP в компактный PNG без потерь.'
|
||||
),
|
||||
decodeToPng(
|
||||
'ico-to-png',
|
||||
'Конвертировать ICO в PNG',
|
||||
'Превращает иконку .ico в обычный PNG нужного размера.'
|
||||
),
|
||||
{
|
||||
id: 'png-to-bmp',
|
||||
title: 'Конвертировать PNG в BMP',
|
||||
description:
|
||||
'Sохраняет изображение в 24-битный BMP без альфа-канала: прозрачность заменяется чёрным фоном.',
|
||||
category: 'convert',
|
||||
params: [],
|
||||
run: (img) => flattenOntoColor(img, '#000000'),
|
||||
output: { mime: 'image/bmp', ext: 'bmp' }
|
||||
},
|
||||
{
|
||||
id: 'png-to-base64',
|
||||
title: 'PNG в Base64',
|
||||
description: 'Кодирует изображение в base64-строку для вставки в код или стили.',
|
||||
category: 'convert',
|
||||
params: [],
|
||||
resultType: 'text',
|
||||
toText: (img) => toBase64(img)
|
||||
},
|
||||
{
|
||||
id: 'base64-to-png',
|
||||
title: 'Base64 в PNG',
|
||||
description:
|
||||
'Dекодирует base64-строку или data-uri обратно в картинку. Вставьте строку слева.',
|
||||
category: 'convert',
|
||||
sourceMode: 'text',
|
||||
params: [],
|
||||
run: (img) => clonePixelImage(img)
|
||||
},
|
||||
{
|
||||
id: 'png-to-data-uri',
|
||||
title: 'PNG в Data URI',
|
||||
description: 'Строит полный data-uri (data:image/png;base64,…) для встраивания в HTML/CSS.',
|
||||
category: 'convert',
|
||||
params: [],
|
||||
resultType: 'text',
|
||||
toText: (img) => toDataUrl(img)
|
||||
},
|
||||
{
|
||||
id: 'data-uri-to-png',
|
||||
title: 'Data URI в PNG',
|
||||
description: 'Dекодирует data:image/…;base64,… обратно в файл картинки.',
|
||||
category: 'convert',
|
||||
sourceMode: 'text',
|
||||
params: [],
|
||||
run: (img) => clonePixelImage(img)
|
||||
},
|
||||
{
|
||||
id: 'png-to-hex',
|
||||
title: 'PNG в HEX-пиксели',
|
||||
description:
|
||||
'Показывает все пиксели как hex-значения rrggbbaa — по строкам, через пробел.',
|
||||
category: 'convert',
|
||||
params: [],
|
||||
resultType: 'text',
|
||||
toText: (img) => pixelsToHex(img)
|
||||
},
|
||||
{
|
||||
id: 'hex-to-png',
|
||||
title: 'HEX-пиксели в PNG',
|
||||
description:
|
||||
'Sобирает картинку из hex-значений rrggbbaa (через пробел). Укажите ширину — высота рассчитается сама.',
|
||||
category: 'convert',
|
||||
sourceMode: 'text',
|
||||
params: [
|
||||
{ id: 'width', label: 'Ширина изображения', type: 'number', min: 1, max: 10000, step: 1, default: 1 }
|
||||
],
|
||||
runFromText: (text, p) => hexToPixels(text, Math.trunc(Number(p['width']))),
|
||||
run: (img) => clonePixelImage(img)
|
||||
},
|
||||
{
|
||||
id: 'resize-png',
|
||||
title: 'Изменить размер PNG',
|
||||
description:
|
||||
'Масштабирование изображения с билинейной интерполяцией. При сохранении пропорций одна сторона задаёт масштаб, а если указаны обе — изображение вписывается в эти размеры.',
|
||||
category: 'geometry',
|
||||
params: [
|
||||
{ id: 'width', label: 'Ширина (0 — авто)', type: 'number', min: 0, max: 20000, step: 1, default: 0 },
|
||||
{ id: 'height', label: 'Высота (0 — авто)', type: 'number', min: 0, max: 20000, step: 1, default: 0 },
|
||||
{ id: 'keepAspect', label: 'Сохранять пропорции', type: 'checkbox', default: true }
|
||||
],
|
||||
run: (img, p) => {
|
||||
const keepAspect = p['keepAspect'] === true;
|
||||
let w = Math.trunc(num(p, 'width'));
|
||||
let h = Math.trunc(num(p, 'height'));
|
||||
if (keepAspect) {
|
||||
if (w > 0 && h > 0) {
|
||||
const scale = Math.min(w / img.width, h / img.height);
|
||||
w = Math.max(1, Math.round(img.width * scale));
|
||||
h = Math.max(1, Math.round(img.height * scale));
|
||||
} else if (w > 0) {
|
||||
h = Math.max(1, Math.round((img.height / img.width) * w));
|
||||
} else if (h > 0) {
|
||||
w = Math.max(1, Math.round((img.width / img.height) * h));
|
||||
}
|
||||
}
|
||||
if (w <= 0 || h <= 0) {
|
||||
throw new Error('Укажите ширину и/или высоту нового размера');
|
||||
}
|
||||
return resize(img, w, h);
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'crop-png',
|
||||
title: 'Обрезать PNG',
|
||||
description:
|
||||
'Вырезает прямоугольную область. Координаты и размеры выходят за границы изображения — область усекается до пересечения с картинкой.',
|
||||
category: 'geometry',
|
||||
params: [
|
||||
{ id: 'x', label: 'X (слева)', type: 'number', min: -100000, max: 100000, step: 1, default: 0 },
|
||||
{ id: 'y', label: 'Y (сверху)', type: 'number', min: -100000, max: 100000, step: 1, default: 0 },
|
||||
{ id: 'width', label: 'Ширина области', type: 'number', min: -100000, max: 100000, step: 1, default: 0 },
|
||||
{ id: 'height', label: 'Высота области', type: 'number', min: -100000, max: 100000, step: 1, default: 0 }
|
||||
],
|
||||
run: (img, p) => {
|
||||
const w = Math.trunc(num(p, 'width'));
|
||||
const h = Math.trunc(num(p, 'height'));
|
||||
if (w <= 0 || h <= 0) {
|
||||
throw new Error('Укажите ширину и высоту области обрезки');
|
||||
}
|
||||
return crop(img, Math.trunc(num(p, 'x')), Math.trunc(num(p, 'y')), w, h);
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'rotate-png',
|
||||
title: 'Повернуть PNG',
|
||||
description: 'Поворот на 90°, 180° или 270° по часовой стрелке без потери качества.',
|
||||
category: 'geometry',
|
||||
params: [
|
||||
{
|
||||
id: 'angle',
|
||||
label: 'Угол поворота',
|
||||
type: 'select',
|
||||
default: '90',
|
||||
options: [
|
||||
{ value: '90', label: '90° по часовой' },
|
||||
{ value: '180', label: '180°' },
|
||||
{ value: '270', label: '270° по часовой' }
|
||||
]
|
||||
}
|
||||
],
|
||||
run: (img, p) => rotate90(img, Number(str(p, 'angle')) / 90)
|
||||
},
|
||||
{
|
||||
id: 'flip-png',
|
||||
title: 'Отразить PNG',
|
||||
description: 'Зеркальное отражение по горизонтали или вертикали без потери качества.',
|
||||
category: 'geometry',
|
||||
params: [
|
||||
{
|
||||
id: 'axis',
|
||||
label: 'Ось отражения',
|
||||
type: 'select',
|
||||
default: 'horizontal',
|
||||
options: [
|
||||
{ value: 'horizontal', label: 'По горизонтали (слева направо)' },
|
||||
{ value: 'vertical', label: 'По вертикали (сверху вниз)' }
|
||||
]
|
||||
}
|
||||
],
|
||||
run: (img, p) => flip(img, str(p, 'axis') === 'vertical' ? 'vertical' : 'horizontal')
|
||||
},
|
||||
{
|
||||
id: 'grayscale-png',
|
||||
title: 'Чёрно-белый PNG',
|
||||
description: 'Переводит изображение в оттенки серого по яркостной формуле BT.601. Альфа сохраняется.',
|
||||
category: 'color',
|
||||
params: [],
|
||||
run: (img) => grayscale(img)
|
||||
},
|
||||
{
|
||||
id: 'invert-colors-png',
|
||||
title: 'Инвертировать цвета PNG',
|
||||
description: 'Обращает каждый цветовой канал (255 − значение). Альфа не меняется.',
|
||||
category: 'color',
|
||||
params: [],
|
||||
run: (img) => invert(img)
|
||||
},
|
||||
{
|
||||
id: 'adjust-brightness-contrast-png',
|
||||
title: 'Яркость и контраст PNG',
|
||||
description: 'Изменяет яркость и контраст в диапазоне от −100 до +100. Значение 0 — без изменений.',
|
||||
category: 'color',
|
||||
params: [
|
||||
{ id: 'brightness', label: 'Яркость', type: 'slider', min: -100, max: 100, step: 1, default: 0 },
|
||||
{ id: 'contrast', label: 'Контраст', type: 'slider', min: -100, max: 100, step: 1, default: 0 }
|
||||
],
|
||||
run: (img, p) => brightnessContrast(img, num(p, 'brightness'), num(p, 'contrast'))
|
||||
},
|
||||
{
|
||||
id: 'change-png-opacity',
|
||||
title: 'Изменить прозрачность PNG',
|
||||
description:
|
||||
'Умножает альфа-канал на процент: 0% — полностью прозрачный, 100% — без изменений.',
|
||||
category: 'color',
|
||||
params: [
|
||||
{ id: 'percent', label: 'Прозрачность, %', type: 'slider', min: 0, max: 100, step: 1, default: 100 }
|
||||
],
|
||||
run: (img, p) => setOpacity(img, num(p, 'percent'))
|
||||
},
|
||||
{
|
||||
id: 'sepia-png',
|
||||
title: 'Эффект сепии',
|
||||
description: 'Тонирует изображение в тёплые коричневые тона классической сепии.',
|
||||
category: 'color',
|
||||
params: [],
|
||||
run: (img) => sepia(img)
|
||||
},
|
||||
{
|
||||
id: 'change-png-hue',
|
||||
title: 'Сменить оттенок PNG',
|
||||
description: 'Сдвиг цветового тона по кругу. Насыщенность и яркость сохраняются.',
|
||||
category: 'color',
|
||||
params: [
|
||||
{ id: 'degrees', label: 'Сдвиг тона, °', type: 'slider', min: -180, max: 180, step: 1, default: 0 }
|
||||
],
|
||||
run: (img, p) => changeHue(img, num(p, 'degrees'))
|
||||
},
|
||||
{
|
||||
id: 'extract-channel-png',
|
||||
title: 'Извлечь канал PNG',
|
||||
description: 'Оставляет выбранный канал — красный, зелёный или синий — в оттенках серого.',
|
||||
category: 'color',
|
||||
params: [
|
||||
{
|
||||
id: 'channel',
|
||||
label: 'Канал',
|
||||
type: 'select',
|
||||
default: 'red',
|
||||
options: [
|
||||
{ value: 'red', label: 'Красный' },
|
||||
{ value: 'green', label: 'Зелёный' },
|
||||
{ value: 'blue', label: 'Синий' }
|
||||
]
|
||||
}
|
||||
],
|
||||
run: (img, p) => extractChannel(img, str(p, 'channel') as RgbChannel)
|
||||
},
|
||||
{
|
||||
id: 'swap-channels-png',
|
||||
title: 'Переставить каналы PNG',
|
||||
description: 'Меняет местами два цветовых канала — быстрый способ получить необычный окрас.',
|
||||
category: 'color',
|
||||
params: [
|
||||
{
|
||||
id: 'pair',
|
||||
label: 'Пара каналов',
|
||||
type: 'select',
|
||||
default: 'r-g',
|
||||
options: [
|
||||
{ value: 'r-g', label: 'Красный ↔ Зелёный' },
|
||||
{ value: 'r-b', label: 'Красный ↔ Синий' },
|
||||
{ value: 'g-b', label: 'Зелёный ↔ Синий' }
|
||||
]
|
||||
}
|
||||
],
|
||||
run: (img, p) => swapChannels(img, str(p, 'pair') as ChannelSwapPair)
|
||||
},
|
||||
{
|
||||
id: 'black-and-white-png',
|
||||
title: 'Чёрно-белый PNG по порогу',
|
||||
description: 'Жёсткая бинаризация по яркости: каждый пиксель становится чёрным или белым.',
|
||||
category: 'color',
|
||||
params: [
|
||||
{ id: 'threshold', label: 'Порог яркости, %', type: 'slider', min: 0, max: 100, step: 1, default: 50 }
|
||||
],
|
||||
run: (img, p) => thresholdBlackWhite(img, num(p, 'threshold'))
|
||||
},
|
||||
{
|
||||
id: 'posterize-png',
|
||||
title: 'Постеризация PNG',
|
||||
description: 'Уменьшает число уровней каждого канала — плакатный эффект.',
|
||||
category: 'color',
|
||||
params: [
|
||||
{ id: 'levels', label: 'Уровней на канал', type: 'slider', min: 2, max: 16, step: 1, default: 4 }
|
||||
],
|
||||
run: (img, p) => posterize(img, num(p, 'levels'))
|
||||
},
|
||||
{
|
||||
id: 'two-colors-png',
|
||||
title: 'Два цвета PNG',
|
||||
description: 'Перекрашивает изображение в два выбранных цвета по порогу яркости.',
|
||||
category: 'color',
|
||||
params: [
|
||||
{ id: 'lightColor', label: 'Цвет светлых участков', type: 'color', default: '#ffffff' },
|
||||
{ id: 'darkColor', label: 'Цвет тёмных участков', type: 'color', default: '#000000' },
|
||||
{ id: 'threshold', label: 'Порог яркости, %', type: 'slider', min: 0, max: 100, step: 1, default: 50 }
|
||||
],
|
||||
run: (img, p) => twoColors(img, str(p, 'lightColor'), str(p, 'darkColor'), num(p, 'threshold'))
|
||||
},
|
||||
{
|
||||
id: 'convert-png-to-jpg',
|
||||
title: 'Конвертировать PNG в JPG',
|
||||
description:
|
||||
'Прозрачность накладывается на выбранный цвет подложки (по умолчанию белый), результат сохраняется в JPEG.',
|
||||
category: 'convert',
|
||||
params: [
|
||||
{ id: 'background', label: 'Цвет подложки', type: 'color', default: '#ffffff' },
|
||||
{ id: 'quality', label: 'Качество JPEG', type: 'slider', min: 1, max: 100, step: 1, default: 90 }
|
||||
],
|
||||
output: { mime: 'image/jpeg', ext: 'jpg', qualityParamId: 'quality' },
|
||||
run: (img, p) => flattenOntoColor(img, str(p, 'background'))
|
||||
},
|
||||
{
|
||||
id: 'convert-png-to-webp',
|
||||
title: 'Конвертировать PNG в WebP',
|
||||
description: 'Перекодирует изображение в WebP с настраиваемым качеством. Прозрачность сохраняется.',
|
||||
category: 'convert',
|
||||
params: [{ id: 'quality', label: 'Качество WebP', type: 'slider', min: 1, max: 100, step: 1, default: 90 }],
|
||||
output: { mime: 'image/webp', ext: 'webp', qualityParamId: 'quality' },
|
||||
run: (img) => clonePixelImage(img)
|
||||
},
|
||||
{
|
||||
id: 'invert-alpha-png',
|
||||
title: 'Инвертировать альфа-канал PNG',
|
||||
description: 'Непрозрачные области становятся прозрачными и наоборот.',
|
||||
category: 'alpha',
|
||||
params: [],
|
||||
run: (img) => invertAlpha(img)
|
||||
},
|
||||
{
|
||||
id: 'remove-color-from-png',
|
||||
title: 'Удалить цвет из PNG (прозрачность)',
|
||||
description:
|
||||
'Делает прозрачными все пиксели, близкие к выбранному цвету. Порог задаёт допустимое отклонение в процентах от максимального цветового расстояния.',
|
||||
category: 'alpha',
|
||||
params: [
|
||||
{ id: 'targetColor', label: 'Цвет для удаления', type: 'color', default: '#00ff00' },
|
||||
{ id: 'tolerance', label: 'Порог похожести, %', type: 'slider', min: 0, max: 100, step: 1, default: 10 }
|
||||
],
|
||||
run: (img, p) => removeColorToAlpha(img, str(p, 'targetColor'), num(p, 'tolerance')),
|
||||
preview: (img, p) => colorMask(img, str(p, 'targetColor'), num(p, 'tolerance'))
|
||||
},
|
||||
{
|
||||
id: 'png-info',
|
||||
title: 'Информация о PNG',
|
||||
description:
|
||||
'Показывает размеры, наличие альфа-канала и количество уникальных цветов загруженного изображения.',
|
||||
category: 'analyze',
|
||||
params: [],
|
||||
resultType: 'info',
|
||||
run: (img) => clonePixelImage(img)
|
||||
}
|
||||
];
|
||||
|
||||
export function getTool(id: string): ToolEntry | undefined {
|
||||
return TOOLS.find((tool) => tool.id === id);
|
||||
}
|
||||
|
||||
export function defaultParams(tool: ToolEntry): Record<string, unknown> {
|
||||
return Object.fromEntries(tool.params.map((p) => [p.id, p.default]));
|
||||
}
|
||||
|
||||
export function sanitizeParams(
|
||||
tool: ToolEntry,
|
||||
values: Record<string, unknown>
|
||||
): Record<string, unknown> {
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const param of tool.params) {
|
||||
const raw = values[param.id];
|
||||
switch (param.type) {
|
||||
case 'number':
|
||||
case 'slider': {
|
||||
const n =
|
||||
typeof raw === 'number' && Number.isFinite(raw) ? raw : param.default;
|
||||
out[param.id] = clampRange(n, param.min, param.max);
|
||||
break;
|
||||
}
|
||||
case 'select':
|
||||
out[param.id] =
|
||||
typeof raw === 'string' && param.options.some((o) => o.value === raw)
|
||||
? raw
|
||||
: param.default;
|
||||
break;
|
||||
case 'checkbox':
|
||||
out[param.id] = typeof raw === 'boolean' ? raw : param.default;
|
||||
break;
|
||||
case 'color':
|
||||
out[param.id] =
|
||||
typeof raw === 'string' && /^#[0-9a-f]{6}$/i.test(raw) ? raw : param.default;
|
||||
break;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function clampRange(value: number, min?: number, max?: number): number {
|
||||
if (min !== undefined && value < min) return min;
|
||||
if (max !== undefined && value > max) return max;
|
||||
return value;
|
||||
}
|
||||
|
||||
export function outputOf(tool: ToolEntry): OutputFormat | undefined {
|
||||
if (tool.resultType === 'info') return undefined;
|
||||
return tool.output ?? PNG_OUTPUT;
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
<script lang="ts">
|
||||
import '../app.css';
|
||||
import favicon from '$lib/assets/favicon.svg';
|
||||
import { CATEGORIES } from '$lib/categories';
|
||||
|
||||
let { children } = $props();
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<link rel="icon" href={favicon} />
|
||||
</svelte:head>
|
||||
|
||||
<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>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
{@render children()}
|
||||
</main>
|
||||
|
||||
<footer>
|
||||
<p class="text-caption text-muted">
|
||||
Все операции выполняются локально в вашем браузере — файлы никуда не отправляются.
|
||||
</p>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.app {
|
||||
min-height: 100dvh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-3);
|
||||
flex-wrap: wrap;
|
||||
padding: var(--space-3) var(--space-4);
|
||||
background: var(--surface);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.brand {
|
||||
font-weight: 700;
|
||||
font-size: 1.1rem;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.brand:hover {
|
||||
text-decoration: none;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
nav {
|
||||
display: flex;
|
||||
gap: var(--space-3);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.nav-link {
|
||||
color: var(--text-muted);
|
||||
font-size: var(--text-s);
|
||||
padding: var(--space-1) var(--space-2);
|
||||
border-radius: var(--radius-s);
|
||||
}
|
||||
|
||||
.nav-link:hover {
|
||||
color: var(--accent);
|
||||
background: var(--bg);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
main {
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
padding: var(--space-4) var(--space-5);
|
||||
}
|
||||
|
||||
footer {
|
||||
padding: var(--space-3) var(--space-4);
|
||||
border-top: 1px solid var(--border);
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1 @@
|
||||
export const prerender = true;
|
||||
@@ -0,0 +1,97 @@
|
||||
<script lang="ts">
|
||||
import { CATEGORIES } from '$lib/categories';
|
||||
import { TOOLS } from '$lib/registry';
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>easy-png-tools — PNG-утилиты прямо в браузере</title>
|
||||
<meta
|
||||
name="description"
|
||||
content="Набор PNG-утилит, которые работают полностью локально в вашем браузере: конвертация, прозрачность, цвет, геометрия и анализ изображений."
|
||||
/>
|
||||
</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}
|
||||
</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);
|
||||
}
|
||||
|
||||
.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 {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.card-desc {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,19 @@
|
||||
<script lang="ts">
|
||||
import ToolPage from '$lib/components/ToolPage.svelte';
|
||||
import { getTool } from '$lib/registry';
|
||||
|
||||
let { data } = $props();
|
||||
|
||||
const tool = $derived(getTool(data.id));
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{tool?.title ?? 'Инструмент'} — easy-png-tools</title>
|
||||
{#if tool}
|
||||
<meta name="description" content={tool.description} />
|
||||
{/if}
|
||||
</svelte:head>
|
||||
|
||||
{#if tool}
|
||||
<ToolPage {tool} />
|
||||
{/if}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { error } from '@sveltejs/kit';
|
||||
import { getTool, TOOLS } from '$lib/registry';
|
||||
import type { EntryGenerator, PageLoad } from './$types';
|
||||
|
||||
export const entries: EntryGenerator = () => TOOLS.map((tool) => ({ id: tool.id }));
|
||||
|
||||
export const load: PageLoad = ({ params }) => {
|
||||
const tool = getTool(params.id);
|
||||
if (!tool) {
|
||||
error(404, 'Инструмент не найден');
|
||||
}
|
||||
return { id: tool.id };
|
||||
};
|
||||
@@ -0,0 +1,3 @@
|
||||
# allow crawling everything by default
|
||||
User-agent: *
|
||||
Disallow:
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"extends": "./.svelte-kit/tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"rewriteRelativeImportExtensions": true,
|
||||
"allowJs": true,
|
||||
"checkJs": true,
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"skipLibCheck": true,
|
||||
"sourceMap": true,
|
||||
"strict": true,
|
||||
"moduleResolution": "bundler"
|
||||
}
|
||||
// Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias
|
||||
// except $lib which is handled by https://svelte.dev/docs/kit/configuration#files
|
||||
//
|
||||
// To make changes to top-level options such as include and exclude, we recommend extending
|
||||
// the generated config; see https://svelte.dev/docs/kit/configuration#typescript
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import adapter from '@sveltejs/adapter-static';
|
||||
import { sveltekit } from '@sveltejs/kit/vite';
|
||||
import { defineConfig } from 'vite';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
sveltekit({
|
||||
compilerOptions: {
|
||||
// Force runes mode for the project, except for libraries. Can be removed in svelte 6.
|
||||
runes: ({ filename }) =>
|
||||
filename.split(/[/\\]/).includes('node_modules') ? undefined : true
|
||||
},
|
||||
|
||||
adapter: adapter({
|
||||
pages: 'build',
|
||||
assets: 'build',
|
||||
fallback: undefined,
|
||||
precompress: false,
|
||||
strict: true
|
||||
}),
|
||||
|
||||
prerender: {
|
||||
handleMissingId: 'warn'
|
||||
}
|
||||
})
|
||||
]
|
||||
});
|
||||
@@ -0,0 +1,8 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
include: ['src/**/*.test.ts'],
|
||||
environment: 'node'
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user