Compare commits

..
34 Commits
Author SHA1 Message Date
Ku6epXBOCTuK 7fa2c52bb8 feat: add core operations 2026-08-23 11:14:33 +05:00
Ku6epXBOCTuK 17b0c68eb5 feat: add text\hex conver tools 2026-08-23 10:40:46 +05:00
Ku6epXBOCTuK 3b18d0c6f8 docs: add common tools and components 2026-08-23 08:20:14 +05:00
Ku6epXBOCTuK b48a8adfa7 docs: update plans 2026-08-23 08:05:55 +05:00
Ku6epXBOCTuK 96c37aabc5 feat: add color picker glass 2026-08-23 07:29:29 +05:00
Ku6epXBOCTuK 7b799969c4 feat: add color picker 2026-08-23 06:12:37 +05:00
Ku6epXBOCTuK 3701fc8045 feat: add slider field 2026-08-23 05:18:10 +05:00
Ku6epXBOCTuK 7e391a420b docs: update plans 2026-08-23 04:54:40 +05:00
Ku6epXBOCTuK aa5372b771 feat: add show mask workflow 2026-08-23 04:06:02 +05:00
Ku6epXBOCTuK d0ee4ec77f feat: reactive tool flow, with debounce 2026-08-22 21:06:50 +05:00
Ku6epXBOCTuK 5560435b55 refactor: separate tool page 2026-08-22 20:52:03 +05:00
Ku6epXBOCTuK dcc7c346c4 docs: update docs - plan, archive 2026-08-22 20:33:01 +05:00
Ku6epXBOCTuK da92815b3a test: add io tests 2026-08-22 19:03:22 +05:00
Ku6epXBOCTuK caf71ec8f0 feat: update drop file flow 2026-08-22 17:57:38 +05:00
Ku6epXBOCTuK f576701baa feat: add ctrl+v flow 2026-08-22 16:49:26 +05:00
Ku6epXBOCTuK 4c5b52a9ad fix: update layout 2026-08-22 16:28:01 +05:00
Ku6epXBOCTuK 7afd07e8c1 refactor: dropzone update 2026-08-22 15:04:49 +05:00
Ku6epXBOCTuK 06c6716cf5 docs: add update ux plan 2026-08-22 14:59:05 +05:00
Ku6epXBOCTuK a9234d27de refactor: remove unused styles 2026-08-22 14:40:39 +05:00
Ku6epXBOCTuK c7b56bbda7 feat: update - use design system components 2026-08-22 14:34:27 +05:00
Ku6epXBOCTuK 15e2e010ec feat: add design system components 2026-08-22 14:02:06 +05:00
Ku6epXBOCTuK 3620c5fc30 feat: add design system tokens 2026-08-22 13:16:05 +05:00
Ku6epXBOCTuK c90f887d7d docs: add design-system plan 2026-08-22 12:23:37 +05:00
Ku6epXBOCTuK cd158b188a docs: update backlog and mvp plan 2026-08-22 12:02:03 +05:00
Ku6epXBOCTuK da55328491 fix: update user input flow (keep aspect ratio) 2026-08-22 11:33:10 +05:00
Ku6epXBOCTuK 4b7086693c fix: resolve loading image error 2026-08-22 10:37:37 +05:00
Ku6epXBOCTuK 08038ede0d chore: add run script 2026-08-22 10:37:04 +05:00
Ku6epXBOCTuK 3ec11999d8 feat: add route pages 2026-08-22 10:25:35 +05:00
Ku6epXBOCTuK c0225552e4 feat: add svelte components 2026-08-22 10:20:18 +05:00
Ku6epXBOCTuK 719bda2e22 feat: add instruments registry 2026-08-22 10:06:49 +05:00
Ku6epXBOCTuK 9ccc9f94ac feat: core instruments 2026-08-22 09:49:53 +05:00
Ku6epXBOCTuK 4ec2b418fd feat: core types and io 2026-08-22 09:29:56 +05:00
Ku6epXBOCTuK 85f595f14b feat: initial sveltekit 2026-08-22 09:16:10 +05:00
Ku6epXBOCTuK d71f8648f5 docs: add mvp plan 2026-08-22 09:07:22 +05:00
72 changed files with 6082 additions and 4 deletions
+1
View File
@@ -0,0 +1 @@
node_modules
+109
View File
@@ -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`.
- Никаких сложных составных компонентов (модалки, таблицы) — они появятся, когда потребуется реальная задача.
+166
View File
@@ -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; параметры применяются; результат скачивается; всё работает офлайн со статического хостинга; ни одной серверной зависимости.
+69
View File
@@ -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. Что сознательно не делаем
- Пипетка и лупа на превью результата и маски — только исходник.
- Кастомная стилизация слайдеров за пределами дизайн-системы.
- Лупа для тач-устройств — там прямой тап без лупы.
+66
View File
@@ -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 мс.
- Редактор масок (кисть, слои, история) — только автогенерация из параметров инструмента.
- Передача результата между инструментами — придёт вместе с многошаговым режимом позже.
+74
View File
@@ -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. Что сознательно не делаем (граница скоупа этого плана)
- Множественная загрузка / пакетная обработка — вне скоупа этого плана.
- Камера/скриншот-инструменты захвата — только буфер обмена.
- Прогресс-бары декодирования — файлы читаются быстро, достаточно индикации «Обработка…».
+1
View File
@@ -0,0 +1 @@
# Backlog
+68
View File
@@ -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
View File
@@ -1,7 +1,5 @@
# План разработки: easy-png-tools
Сопутствующий документ: [analysis.md](./analysis.md) — перечень и уровни утилит.
## 0. Решения
- **Набор PNG-утилит, всё в браузере** (SvelteKit + `adapter-static`), данные не покидают машину.
@@ -41,7 +39,7 @@ easy-png-tools/
1.4 **Пайплайн-workspace** (`/workspace`): загрузил изображение → список шагов (инструмент + его параметры) → последовательное применение с превью каждого шага → скачивание финального результата. Добавление/удаление/перестановка шагов. Сохранение пайплайнов в localStorage, экспорт/импорт JSON. Общие компоненты: DropZone, Preview, ParamForm, Download, PipelineSteps.
1.5 **Массовая реализация EASY-инструментов** по категориям из analysis.md:
1.5 **Массовая реализация EASY-инструментов** по категориям:
- конвертация форматов (png/jpg/webp/bmp/base64/gif) и текстовые представления;
- прозрачность и альфа-канал;
+2 -1
View File
@@ -4,7 +4,8 @@
"description": "",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
"test": "echo \"Error: no test specified\" && exit 1",
"dev": "pnpm --dir web dev"
},
"keywords": [],
"author": "Ku6epXBOCTuK",
+208
View File
@@ -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:
.: {}
+23
View File
@@ -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-*
+1
View File
@@ -0,0 +1 @@
engine-strict=true
+3
View File
@@ -0,0 +1,3 @@
{
"recommendations": ["svelte.svelte-vscode"]
}
+42
View File
@@ -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.
+25
View File
@@ -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"
}
}
+1040
View File
File diff suppressed because it is too large Load Diff
+2
View File
@@ -0,0 +1,2 @@
allowBuilds:
esbuild: true
+156
View File
@@ -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;
}
+13
View File
@@ -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 {};
+12
View File
@@ -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>
+1
View File
@@ -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

+11
View File
@@ -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>
+96
View File
@@ -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>
+116
View File
@@ -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>
+50
View File
@@ -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>
+60
View File
@@ -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}
+109
View File
@@ -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>
+272
View File
@@ -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>
+83
View File
@@ -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>
+29
View File
@@ -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>
+120
View File
@@ -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/);
});
});
+90
View File
@@ -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));
}
+45
View File
@@ -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);
});
});
+26
View File
@@ -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 };
}
+52
View File
@@ -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);
});
});
+37
View File
@@ -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;
}
+185
View File
@@ -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);
});
});
+225
View File
@@ -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));
}
+157
View File
@@ -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);
});
});
+106
View File
@@ -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)));
}
+31
View File
@@ -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('неизвестный');
});
});
+125
View File
@@ -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}`;
}
+14
View File
@@ -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());
}
+47
View File
@@ -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();
});
});
+45
View File
@@ -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');
}
+17
View File
@@ -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() };
}
+1
View File
@@ -0,0 +1 @@
// place files you want to import through the `$lib` alias in this folder.
+203
View File
@@ -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;
}
+529
View File
@@ -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;
}
+93
View File
@@ -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>
+1
View File
@@ -0,0 +1 @@
export const prerender = true;
+97
View File
@@ -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>
+19
View File
@@ -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}
+13
View File
@@ -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 };
};
+3
View File
@@ -0,0 +1,3 @@
# allow crawling everything by default
User-agent: *
Disallow:
+20
View File
@@ -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
}
+27
View File
@@ -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'
}
})
]
});
+8
View File
@@ -0,0 +1,8 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
include: ['src/**/*.test.ts'],
environment: 'node'
}
});