Compare commits
34
Commits
6c9f9470c8
...
7fa2c52bb8
| 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
|
# План разработки: easy-png-tools
|
||||||
|
|
||||||
Сопутствующий документ: [analysis.md](./analysis.md) — перечень и уровни утилит.
|
|
||||||
|
|
||||||
## 0. Решения
|
## 0. Решения
|
||||||
|
|
||||||
- **Набор PNG-утилит, всё в браузере** (SvelteKit + `adapter-static`), данные не покидают машину.
|
- **Набор PNG-утилит, всё в браузере** (SvelteKit + `adapter-static`), данные не покидают машину.
|
||||||
@@ -41,7 +39,7 @@ easy-png-tools/
|
|||||||
|
|
||||||
1.4 **Пайплайн-workspace** (`/workspace`): загрузил изображение → список шагов (инструмент + его параметры) → последовательное применение с превью каждого шага → скачивание финального результата. Добавление/удаление/перестановка шагов. Сохранение пайплайнов в localStorage, экспорт/импорт JSON. Общие компоненты: DropZone, Preview, ParamForm, Download, PipelineSteps.
|
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) и текстовые представления;
|
- конвертация форматов (png/jpg/webp/bmp/base64/gif) и текстовые представления;
|
||||||
- прозрачность и альфа-канал;
|
- прозрачность и альфа-канал;
|
||||||
|
|||||||
+2
-1
@@ -4,7 +4,8 @@
|
|||||||
"description": "",
|
"description": "",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"test": "echo \"Error: no test specified\" && exit 1"
|
"test": "echo \"Error: no test specified\" && exit 1",
|
||||||
|
"dev": "pnpm --dir web dev"
|
||||||
},
|
},
|
||||||
"keywords": [],
|
"keywords": [],
|
||||||
"author": "Ku6epXBOCTuK",
|
"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