Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0b107c7d93 | ||
|
|
24cd9f78c6 | ||
|
|
d14e14ac1a | ||
|
|
b91c1ab7ee | ||
|
|
2be8871930 | ||
|
|
adb578540f | ||
|
|
a7a42d0091 | ||
|
|
e1c3d0a95f | ||
|
|
ceee9adead | ||
|
|
d94c5d1297 | ||
|
|
ee5f660572 | ||
|
|
4826fc6ad8 | ||
|
|
1e47fdaf4a | ||
|
|
8451769efd | ||
|
|
cda88547c4 | ||
|
|
d8a612fbc4 | ||
|
|
fa5f7660c7 |
@@ -0,0 +1,64 @@
|
||||
name: Deploy to GitHub Pages
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pages: write
|
||||
id-token: write
|
||||
|
||||
concurrency:
|
||||
group: pages
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: ^11.20.0
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 24
|
||||
cache: pnpm
|
||||
cache-dependency-path: web/pnpm-lock.yaml
|
||||
|
||||
- name: Configure Pages
|
||||
uses: actions/configure-pages@v5
|
||||
|
||||
- name: Install dependencies
|
||||
working-directory: web
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Build
|
||||
working-directory: web
|
||||
run: pnpm build
|
||||
env:
|
||||
BASE_PATH: /easy-png-tools
|
||||
|
||||
- name: Upload Pages artifact
|
||||
uses: actions/upload-pages-artifact@v3
|
||||
with:
|
||||
path: web/build
|
||||
|
||||
deploy:
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
environment:
|
||||
name: github-pages
|
||||
url: ${{ steps.deployment.outputs.page_url }}
|
||||
steps:
|
||||
- name: Deploy
|
||||
id: deployment
|
||||
uses: actions/deploy-pages@v4
|
||||
@@ -24,66 +24,40 @@
|
||||
|
||||
## Правила кода
|
||||
|
||||
### Svelte 5: типизация props через `interface Props`
|
||||
Синтаксические ограничения описывать не нужно — они проверяются линтером
|
||||
(весь список правил и токены-префиксы в `web/eslint-plugins/README.md`,
|
||||
прогон: `pnpm --dir web lint` / `pnpm --dir web lint:all`). Здесь — только то,
|
||||
что линтер не умеет.
|
||||
|
||||
Все типизированные пропсы компонентов описываются через локальный
|
||||
`interface Props`, а деструктуризация идёт через аннотацию типа при `$props()`:
|
||||
### Svelte 5
|
||||
|
||||
```svelte
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
Типизация пропсов через локальный `interface Props` закреплена правилом
|
||||
`conventions/interface-props`; руками его нигде дублировать не надо.
|
||||
|
||||
interface Props {
|
||||
label: string;
|
||||
accent?: boolean;
|
||||
children?: Snippet;
|
||||
}
|
||||
### Сначала искать
|
||||
|
||||
let { label, accent = false, children }: Props = $props();
|
||||
</script>
|
||||
```
|
||||
Новая константа или тип не заводится, пока не произведён поиск существующего
|
||||
определения (по ключу в соседних модулях и по всему `web/src/`). Это правило
|
||||
в первую очередь для ИИ-агентов: дублирующее определение ухудшает правки в
|
||||
нескольких местах и запутывает. (Синтаксис самих определений — под линтером:
|
||||
строка `conventions/no-string-union-alias` и т.д.)
|
||||
|
||||
Не использовать инлайн-дженерик `$props<{ ... }>()` — он тяжело читается и
|
||||
разносит тип и деструктуризацию по разным местам. Также **не использовать
|
||||
инлайн-импорты в типах** (`children?: import('svelte').Snippet;`) — все
|
||||
`import type` поднимаются наверх файла.
|
||||
### Дизайн
|
||||
|
||||
> Правило «всегда `interface Props` + `let {...}: Props = $props()`» стандартным
|
||||
> ESLint-правилом не покрывается — остаётся конвенцией.
|
||||
|
||||
### Дизайн: новый визуальный язык
|
||||
|
||||
Описание дизайна — в `docs/plan-redesign.md`. Общие правила:
|
||||
Описание нового визуального языка — в `docs/plan-redesign.md`. Общие правила,
|
||||
которые линтер не проверяет:
|
||||
|
||||
- Новый дизайн живёт в `web/src/app.css` (корневые маршруты), старый — в
|
||||
`web/src/app_v1.css` (маршруты `/v1/*`).
|
||||
- Все повторяющиеся визуальные элементы — отдельные компоненты в
|
||||
`web/src/lib/components/`, даже «просто div с двумя стилями».
|
||||
|
||||
### Линтинг дизайн-токенов
|
||||
|
||||
Запрещено «захардкоживать» дизайн: цвета, размеры, длительности и z-index
|
||||
обязаны приходить из CSS-переменных. Прогон: `pnpm --dir web lint:all`.
|
||||
|
||||
- **Цвета**: только `hct(...)` в `app.css`, seed-токены
|
||||
(`--brand-main`/`--brand-alt`) и `color-mix(...)` — исключения.
|
||||
`oklch()/rgb()/#hex` в `--color-*` запрещены.
|
||||
- **Размеры**: `--space-*`, `--text-*`, `--radius-*`, `--size-*`.
|
||||
- **Breakpoints**: `@custom-media --bp-*` (объявления в `app.css`, используются
|
||||
как `@media (--bp-*)`).
|
||||
- **z-index**: `--z-*`; **длительности**: `--duration-*`, `--ease-*`.
|
||||
- В `<style>` svelte-компонентов: нельзя хардкодить цвета/размеры/длительности,
|
||||
нельзя использовать необъявленные `var(--x)`, нельзя путать категории
|
||||
(color-токен в size-свойстве).
|
||||
|
||||
Детали: полный список правил плагина, токены-префиксы, настройка stylelint — см.
|
||||
`web/eslint-plugins/README.md`.
|
||||
- Дизайн-токены (цвета, размеры, длительности, z-index) — только из
|
||||
CSS-переменных; детали ограничений — в `web/eslint-plugins/README.md`.
|
||||
|
||||
### Изоляция веток old ↔ new
|
||||
|
||||
Старый (`v1/`) и новый UI полностью изолированы: ESLint-правило
|
||||
`isolation/no-mixed-imports` резолвит каждый импорт до файла и запрещает
|
||||
смешивание.
|
||||
Старый (`v1/`) и новый UI изолированы: `isolation/no-mixed-imports` резолвит
|
||||
каждый импорт до файла и запрещает смешивание.
|
||||
|
||||
- Trunk-based: коммиты делает разработчик после ревью, самому не коммитить.
|
||||
Изменения делать небольшими (< ~500 строк), атомарными.
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
# План: инструмент «Разрезать PNG на части» (split-into-parts-png)
|
||||
|
||||
> Статус: **выполнено и в архиве** (2026-09). Инструмент и механизм «результат =
|
||||
> набор файлов» (1 → many) реализованы; первый потребитель — `split-into-parts-png`.
|
||||
> Общий процесс таких инструментов — задача 18 в `docs/backlog.md`.
|
||||
|
||||
## Цель
|
||||
|
||||
Пользователь загружает одну картинку, выбирает число столбцов и строк, картинка
|
||||
разрезается на равномерные части. Результат — набор PNG-файлов, скачивается
|
||||
ZIP-архивом.
|
||||
|
||||
## Решения
|
||||
|
||||
- **Неравномерное деление:** канвас дополняется прозрачным до кратного размера
|
||||
(`pieceW = ceil(w / cols)`, `newW = pieceW * cols`), все части строго равные,
|
||||
картинка покрывается целиком.
|
||||
- **Дефолт схемы:** 2 × 2.
|
||||
- **Zip-библиотека:** `fflate` (zero deps, малый размер), скачивание из UI-слоя
|
||||
(`encode()` требует DOM).
|
||||
- **Результат инструмента:** `result: "files"` — терминальный тип, в пайплайн
|
||||
(следующий шаг) не передаётся.
|
||||
|
||||
## Шаги
|
||||
|
||||
1. **Зависимость:** `fflate` в `web/package.json`.
|
||||
2. **`registry/types.ts`:** `RESULT_KINDS.files`, тип `ToolImageFile`,
|
||||
`FileResult`, расширение `ToolResult = PixelImage | string | FileResult`.
|
||||
3. **`core/geometry.ts`:** `splitToParts(img, columns, rows)` — padding + сетка
|
||||
через `crop`.
|
||||
4. **`web/src/lib/zip.ts` (новый):** `downloadZip(files, zipName)` через
|
||||
`zipSync`.
|
||||
5. **`core/errors.ts`:** ключ `errors.tooManyParts` (страховочный лимит
|
||||
cols·rows ≤ 1000; при максимуме 6×6 недостижим).
|
||||
6. **`registry/geometry.ts`:** схема (`columns`, `rows`, дефолт 2×2, min 1, max
|
||||
6 — максимум 36 частей) + entry `split-into-parts-png` в `geometryEntries`.
|
||||
7. **Executor:** сериализация/десериализация `FileResult` в `executor.worker.ts`
|
||||
и тип ветки в `executor.ts`.
|
||||
8. **UI:** `SchemaToolView.svelte` (`fileResult`, Download → zip),
|
||||
`SchemaPreview.svelte` (canDownload для files), `SchemaResultTile.svelte`
|
||||
(сетка-превью частей, мета «N parts / ZIP»).
|
||||
9. **i18n:** переводы в `ru.ts`/`en.ts` **не добавляем** — словари `tools`
|
||||
привязаны к v1-реестру (тест «нет лишних ключей»), а preview берёт заголовки
|
||||
из registry (EN). Перевод придёт вместе с беклог-задачей «i18n в preview».
|
||||
Поиск по id/заголовку работает из registry.
|
||||
10. **Тесты:** юнит `splitToParts`, registry (result: "files"), i18n coverage.
|
||||
11. **Docs:** `docs/backlog.md` (задача «процесс 1 → many и many → 1», отметить
|
||||
пункт 11 «Мультифайловый вывод»), `docs/tools-map.md` (перенос из идей).
|
||||
|
||||
## Файлы
|
||||
|
||||
| Файл | Изменение |
|
||||
| ------------------------------------------------ | ----------------------------------------------------------------- |
|
||||
| `web/package.json` | `+ fflate` (dependencies) |
|
||||
| `web/src/lib/registry/types.ts` | `RESULT_KINDS.files`, `ToolImageFile`, `FileResult`, `ToolResult` |
|
||||
| `web/src/lib/core/geometry.ts` | `splitToParts()` |
|
||||
| `web/src/lib/zip.ts` | **новый** — `downloadZip()` |
|
||||
| `web/src/lib/core/errors.ts` | `errors.tooManyParts` |
|
||||
| `web/src/lib/registry/geometry.ts` | schema + `splitPartsTool` |
|
||||
| `web/src/lib/executor/executor.worker.ts` | ветка `FileResult` |
|
||||
| `web/src/lib/executor/executor.ts` | тип ветки `FileResult` |
|
||||
| `web/src/lib/components/SchemaToolView.svelte` | `fileResult`, download → zip |
|
||||
| `web/src/lib/components/SchemaPreview.svelte` | props `fileResult`, `canDownload` |
|
||||
| `web/src/lib/components/SchemaResultTile.svelte` | сетка частей, «N parts» |
|
||||
| `web/src/lib/i18n/ru.ts`, `en.ts` | записи инструмента |
|
||||
|
||||
## Нейминг файлов в ZIP
|
||||
|
||||
`part-<row>-<col>.png` (row, col нумеруются с 1, при 2×2 — `part-1-1.png`).
|
||||
|
||||
## Верификация
|
||||
|
||||
```bash
|
||||
pnpm --dir web exec svelte-check --tsconfig ./tsconfig.json
|
||||
pnpm --dir web test
|
||||
pnpm --dir web lint
|
||||
```
|
||||
|
||||
Ручная проверка: PNG не кратного размера (например 101×77), 3×2 → 6 частей
|
||||
одинакового размера, скачивается ZIP; на кратном размере (100×80, 3×2) padding
|
||||
= 0.
|
||||
@@ -2,12 +2,17 @@
|
||||
|
||||
## lint и прочие правила
|
||||
|
||||
- [ ] линт правило для `interface Props {...}` + `let {}: Props = $props()`
|
||||
- [ ] линт правило для `object as const`, нежелательно использовать
|
||||
- [x] линт правило для `interface Props {...}` + `let {}: Props = $props()`
|
||||
(плагин `conventions`, правило `interface-props`; автофикс для
|
||||
нетипизированной деструктуризации)
|
||||
- [x] линт правило для `object as const`, нежелательно использовать
|
||||
`type Kind = 'a' | 'b' | 'c'` - это приводит к дублированию описаний в
|
||||
разных местах, и к непонятным новым типам типа `type Kind2 = 'a' | 'c'`
|
||||
- [ ] можно ли сделать правило для минимизации новых `const` определений? чтобы
|
||||
(правило `conventions/no-string-union-alias`)
|
||||
- [x] можно ли сделать правило для минимизации новых `const` определений? чтобы
|
||||
дубли автоматически определялись? ИИ агент иногда дублирует определения
|
||||
(как линт-правило — нереализуемо без семантики; решено ограничением в
|
||||
AGENTS.md «сначала искать», авто-детект дублей в файле — открытый вопрос)
|
||||
|
||||
## Мелочи всякие
|
||||
|
||||
@@ -24,7 +29,8 @@
|
||||
шрифты с кириллицей? сделать галочку "только с кириллицей"??? (решение о
|
||||
наборе и лицензиях: `docs/plan-platform.md` §4 — только OFL/Apache с
|
||||
паспортом, семейства с кириллицей)
|
||||
- [ ] Посмотреть темную тему - слишком темная???
|
||||
- [ ] Посмотреть темную тему - слишком темная и слишком много оттенка (убавить c
|
||||
в hct?)
|
||||
- [ ] на картинке результате при работе дергается высота надписи (высота иконки)
|
||||
- [x] Глянуть что за ошибка (воспроизвелось на blur-png, в том числе и повторно)
|
||||
[баг в хроме](https://issues.chromium.org/issues/556160936)
|
||||
@@ -85,7 +91,8 @@
|
||||
Общий план: `docs/plan-platform.md`; WASM-ядро и CLI уже в `docs/roadmap.md`
|
||||
(фазы 3–8) — здесь только новые треки.
|
||||
|
||||
- [ ] PWA: manifest + service worker, оффлайн-режим, установка (после C19 и S1)
|
||||
- [ ] PWA: manifest + service worker, оффлайн-режим, установка (после C17 и S1;
|
||||
C19 отложен — не блокер)
|
||||
- [ ] API: спайк серверного ядра (нативный Rust vs edge-wasm), ключи, free tier
|
||||
(после wasm-фаз 5–7)
|
||||
|
||||
@@ -183,8 +190,10 @@
|
||||
reverse-area) — ждут UI выделения области на превью. Из
|
||||
`archive/plan-gap-waves.md` («вне очереди»).
|
||||
|
||||
11. **Мультифайловый вывод** (split-parts, gif-frames, separate-colors) — ждут
|
||||
механизма «результат = набор файлов». Из `archive/plan-gap-waves.md`.
|
||||
11. **Мультифайловые инструменты-потребители** (gif-to-frames, separate-colors)
|
||||
— механизм «результат = набор файлов» (1 → many) реализован: `FileResult` +
|
||||
`result: "files"` + скачивание zip-архивом + грид-превью частей. Первый
|
||||
потребитель — `split-into-parts-png`. Остальные подключаются по мере нужды.
|
||||
|
||||
12. **Анимационные** (slow-reveal/fade/scrolling) — выход не PNG; отдельное
|
||||
решение о формате. Из `archive/plan-gap-waves.md`.
|
||||
@@ -225,3 +234,16 @@
|
||||
отображения (показывает маску или сам результат), и для передачи в следующий
|
||||
инструмент (всегда результат. Оценка — на усмотрение при проработке (S/M).
|
||||
Пока живём без неё.
|
||||
|
||||
18. **Процесс работы с инструментами 1 → many и many → 1** — абстракция
|
||||
результата «набор файлов» оформлена и работает: тип `FileResult`,
|
||||
`result: "files"` в реестре, сериализация через executor, скачивание
|
||||
zip-архивом, грид-превью частей. Реализовано на первом потребителе
|
||||
`split-into-parts-png` (2026-09, `docs/archive/plan-split-into-parts.md`).
|
||||
**1 → many:** подключение остальных потребителей (gif-to-frames,
|
||||
separate-colors) при необходимости; формат/качество файлов внутри zip —
|
||||
отложено до UX-райза «Download» (п.7). **many → 1** (вход = архив/несколько
|
||||
картинок → единый результат): не реализовано. Зависит от batch-обработки
|
||||
архивов (п.5 идей, roadmap фаза 8 «batch-страница»). Нужна отдельная
|
||||
проработка входа (распаковка zip в браузере, порядок файлов, поведение в
|
||||
пайплайне — такие инструменты терминальны, как и 1 → many).
|
||||
|
||||
@@ -12,6 +12,9 @@
|
||||
|
||||
## A. Краевые PNG-файлы
|
||||
|
||||
> Фикстуры для этого раздела генерируются скриптом: `pnpm generate:fixtures` →
|
||||
> `tests/fixtures/manual-edge-cases/`.
|
||||
|
||||
- [ ] Огромное изображение (мегапиксели) — загрузка не висит, прогресс есть.
|
||||
- [ ] PNG 1×1 — инструменты не падают (проверено авто: flip-png, см. suite).
|
||||
- [ ] PNG с полупрозрачностью/чётким альфа-краёв (чёрно-белая шахматка) —
|
||||
@@ -23,6 +26,31 @@
|
||||
- [ ] PNG с битой CRCh / 16-bit — как ведут себя анализаторы (ориентация,
|
||||
размер, прозрачность).
|
||||
|
||||
### Файлы (`tests/fixtures/manual-edge-cases/`)
|
||||
|
||||
| Файл | Описание |
|
||||
| ------------------------ | ------------------------------------------------------------ |
|
||||
| `1x1.png` | Минимальное изображение 1×1, сплошной красный |
|
||||
| `1x1-transparent.png` | 1×1, полностью прозрачный (alpha=0) |
|
||||
| `2x2-extreme.png` | 2×2: чёрный, белый, полупрозрачный красный, прозрачный синий |
|
||||
| `1024x1024-checker.png` | 1024×1024, шахматка ≈1 мегапиксель |
|
||||
| `1920x1080-gradient.png` | 1920×1080, градиент ≈2 мегапикселя |
|
||||
| `alpha-checkerboard.png` | 64×64, чёрно-белая шахматка с чередующейся альфой |
|
||||
| `alpha-gradient.png` | 128×64, альфа-градиент 0→255 по ширине |
|
||||
| `sparse-alpha.png` | 100×100, 10% пикселей с alpha=0 |
|
||||
| `all-black-opaque.png` | 64×64, сплошной чёрный |
|
||||
| `all-white-opaque.png` | 64×64, сплошной белый |
|
||||
| `all-transparent.png` | 64×64, полностью прозрачное |
|
||||
| `palette-4colors.png` | 32×32, палитровый 4 цвета (RGBW) |
|
||||
| `palette-256.png` | 64×64, палитровый полная палитра 256 цветов |
|
||||
| `16bit-subtle.png` | 32×32, 16-bit RGBA, тонкий градиент |
|
||||
| `noise-pattern.png` | 100×100, шумовая текстура |
|
||||
| `1x4000-strip.png` | 1×4000, длинная вертикальная полоса |
|
||||
| `4000x1-strip.png` | 4000×1, широкая горизонтальная полоса |
|
||||
| `corrupt-bad-crc.png` | 4×4, повреждённый CRC в IDAT |
|
||||
| `truncated.png` | Обрезанный файл (только PNG-заголовок + часть IHDR) |
|
||||
| `fake-png.txt` | Текстовый файл «.png» — не-PNG |
|
||||
|
||||
## B. Края параметров
|
||||
|
||||
- [ ] Экстремальные значения слайдеров (0 и max) в blur/sharpen/pixelate — не
|
||||
|
||||
@@ -43,7 +43,7 @@
|
||||
- Гейт: один и тот же пайплайн даёт пиксельно идентичный результат в браузере,
|
||||
CLI и API (harness фазы 7 расширяется третьим рантаймом).
|
||||
|
||||
## 2. Фаза 10 — PWA (после C17/C19 и S1)
|
||||
## 2. Фаза 10 — PWA (после C17 и S1; C19 не блокер)
|
||||
|
||||
- [ ] **[P1]** Манифест: имя (бренд после R1 из plan-seo §6), иконки 192/512 +
|
||||
maskable, theme-color из дизайн-токенов, `display: standalone`.
|
||||
@@ -64,8 +64,8 @@
|
||||
## 3. Порядок и связи
|
||||
|
||||
- WASM-ядро, эталоны, CLI — `docs/roadmap.md` фазы 2–8 (не дублируются).
|
||||
- PWA — после C17/C19 (один дизайн, реальные маршруты) и после S1 `plan-seo.md`
|
||||
(не кешировать noindex-версии); API — после фаз 5–7.
|
||||
- PWA — после C17 (один дизайн, реальные маршруты) и после S1 `plan-seo.md`; C19
|
||||
отложен и PWA не ждёт (не кешировать noindex-версии); API — после фаз 5–7.
|
||||
- Синергия с дистрибуцией: «works offline, installable» и публичный API —
|
||||
аргументы для Product Hunt / alternative.to / GitHub (plan-seo S4c) и для
|
||||
GEO-текстов (`llms.txt`, описания категорий).
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
# План: переезд на новый дизайн (refs) — параллельная сборка
|
||||
|
||||
> Статус: C17 **выполнен** (`docs/archive/plan-c17.md`). Остались C18–C21 (шаг
|
||||
> 5–6) — **следующий этап**. Они больше не привязаны к «верности против рефа»:
|
||||
> `docs/archive/plan-design-fix.md` закрыт, аудит против рефа прекращён. Tech
|
||||
> debt закрыт (`docs/archive/plan-tech-debt.md`). Backlog №14 (переезд старых
|
||||
> файлов в `old/`) — **выполнено**. Покупка домена НЕ была блокером C17: домен
|
||||
> нужен для S1 (`plan-seo.md`) — canonical, sitemap, снятие `noindex`.
|
||||
> Статус: C17 **выполнен** (`docs/archive/plan-c17.md`), C18 **выполнен**.
|
||||
> Остались C20 (шаг 6, проверка нового дизайна) — **следующий этап**. C19/C21
|
||||
> (шаг 7, удаление старого) — **отложены**, нет ценности. Они больше не
|
||||
> привязаны к «верности против рефа»: `docs/archive/plan-design-fix.md` закрыт,
|
||||
> аудит против рефа прекращён. Tech debt закрыт
|
||||
> (`docs/archive/plan-tech-debt.md`). Backlog №14 (переезд старых файлов в
|
||||
> `old/`) — **выполнено**. Покупка домена НЕ была блокером C17: домен нужен для
|
||||
> S1 (`plan-seo.md`) — canonical, sitemap, снятие `noindex`.
|
||||
>
|
||||
> Источники: `refs/` — Next/React-референс (правда по пикселям и интерактиву),
|
||||
> `refs-html/` — статические HTML-снимки для быстрого просмотра в браузере.
|
||||
@@ -380,24 +382,31 @@ src/routes/preview/tools/[id]/+page.svelte
|
||||
- [x] **[C18]** Правки импортов/редиректов после переноса, проверка билда.
|
||||
_(Билд собирается без ошибок, линт/свелт-чека нет — выполнен.)_
|
||||
|
||||
### Шаг 6. Удаление старого
|
||||
### Шаг 6. Проверка нового дизайна
|
||||
|
||||
- [ ] **[C19]** Удалить старое: маршруты `routes/v1/**`, код `lib/v1/**`,
|
||||
`app_v1.css` (и его токены), неиспользуемые классы. К этому моменту `kit/`
|
||||
уже упразднён (C17 разложил его в `components/`), навести порядок в
|
||||
`components/` (согласовать подпапки, финальное имя каталога). **И раскрыть
|
||||
ESLint `recommended` на весь код** — убрать scoped-блок `newCode` в
|
||||
`web/eslint.config.js` (см. AGENTS.md), прогнать `lint` и починить
|
||||
всплывшие ошибки в оставшемся коде. _Примечание: доменные модули
|
||||
(`lib/core`, `lib/registry`, тесты и т.п.) — это не «старый дизайн», у них
|
||||
свой линт-долг; расширение `recommended` на них может выдать много ошибок.
|
||||
Решить на C19: либо чиним сразу, либо расширяем scoped только на
|
||||
`src/routes/**` + `src/lib/components/**`._
|
||||
- [ ] **[C20]** Финальный проход по брейкпоинтам 1200/1100/800/480; build без
|
||||
предупреждений; `grep` по старым токенам/классам пуст.
|
||||
- [ ] **[C21]** _(опционально)_ Удалить `refs/` и `refs-html/` из репо — они
|
||||
больше не источник правды.
|
||||
предупреждений; `grep` по старым токенам/классам пуст. Доказательство
|
||||
отсутствия регресса: старый `v1/` работает и не затронут.
|
||||
|
||||
### Шаг 7. Опциональная чистка (отложен, нет срока)
|
||||
|
||||
- [ ] **[C21]** Удалить `refs/` и `refs-html/` из репо — они больше не источник
|
||||
правды.
|
||||
- [ ] **[C19]** Удалить старое: маршруты `routes/v1/**`, код `lib/v1/**`,
|
||||
`app_v1.css` (и его токены), неиспользуемые классы. **И раскрыть ESLint
|
||||
`recommended` на весь код** — убрать scoped-блок `newCode` в
|
||||
`web/eslint.config.js`, прогнать `lint` и починить всплывшие ошибки.
|
||||
_Примечание: доменные модули (`lib/core`, `lib/registry`, тесты и т.п.) —
|
||||
это не «старый дизайн», у них свой линт-долг; расширение `recommended` на
|
||||
них может выдать много ошибок. Решить на C19: либо чиним сразу, либо
|
||||
расширяем scoped только на `src/routes/**` + `src/lib/components/**`._
|
||||
- [ ] Навести порядок в `components/` (согласовать подпапки, финальное имя
|
||||
каталога).
|
||||
|
||||
> **Удаление v1 не решает никаких текущих задач** и не даёт пользовательской
|
||||
> ценности. Старый код лежит мёртвым, не мешает, не ломает билд. Удалять стоит
|
||||
> только когда нет горящих задач и хочется навести порядок.
|
||||
>
|
||||
> Если какой-то коммит тянет за собой > 500 строк (например, C6/C7), разбивать
|
||||
> на под-коммиты по 2–4 компонента. Тесты/билд прогонять после каждого коммита,
|
||||
> чтобы регрессия локализовалась одним шагом назад.
|
||||
@@ -414,8 +423,6 @@ src/routes/preview/tools/[id]/+page.svelte
|
||||
> удаление `(old)/` + смена root-layout — в ОДНОМ коммите. Снятие `noindex`
|
||||
> также в C17, **если домен уже куплен**; иначе — отложить на S1. Не дробить
|
||||
> сам C17.
|
||||
> - **C19 должен идти строго после C17** (старый `app.css`/`ui/` удаляем только
|
||||
> когда old-сайт уже выключен).
|
||||
> - Очерёдность зависимостей: `design2.css` (C3) и kit-компоненты (C5–C7) должны
|
||||
> существовать до страниц, которые их импортируют (C8–C14).
|
||||
|
||||
|
||||
@@ -2,10 +2,11 @@
|
||||
|
||||
> **Статус:** Фаза 1 (полноценный TS-сайт) в основном выполнена — сайт живёт,
|
||||
> каталог переведён в типизированный `registry` (121/125), новый дизайн по
|
||||
> `plan-redesign.md` стал основным на корневых маршрутах (C17 выполнен), старый
|
||||
> UI — на `/v1/*` (удаление отложено до C19). Tech debt закрыт. Следующий этап:
|
||||
> C18–C21 (`plan-redesign.md`, §10). Домен нужен для SEO (`plan-seo.md`,
|
||||
> S1-domain). Фазы 2–8 — будущие. Фазы 9–10 спланированы в
|
||||
> `plan-redesign.md` стал основным на корневых маршрутах (C17/C18 выполнены),
|
||||
> старый UI — на `/v1/*` (удаление отложено, ценности не даёт — C19 в бэклоге).
|
||||
> Tech debt закрыт. Следующий этап: C20 (проверка нового дизайна,
|
||||
> `plan-redesign.md`, §10) + SEO S1-free (`plan-seo.md`) + баги. Домен нужен для
|
||||
> SEO (`plan-seo.md`, S1-domain). Фазы 2–8 — будущие. Фазы 9–10 спланированы в
|
||||
> `docs/plan-platform.md`.
|
||||
|
||||
## 0. Решения
|
||||
|
||||
@@ -99,6 +99,7 @@
|
||||
- add-border-png — thickness, color
|
||||
- fit-on-background-png — width, height, transparent, color
|
||||
- tile-png — columns, rows
|
||||
- split-into-parts-png — columns, rows (мультифайловый вывод, zip)
|
||||
- trim-empty-space-png — threshold (альфа)
|
||||
- change-canvas-size-png — width, height, anchor (3×3)
|
||||
- change-aspect-ratio-png — ratio (пресеты), mode (crop/pad)
|
||||
@@ -222,9 +223,9 @@
|
||||
превью: censor-region, erase-region, pixelate-area, blur-area, sharpen-area,
|
||||
reverse-colors-area. Один раз делаем selection-компонент — получаем сразу
|
||||
шесть инструментов.
|
||||
- **Мультифайловый вывод** — сейчас инструмент отдаёт одну картинку:
|
||||
split-into-parts, gif-to-frames, separate-colors, multiply-grid-as-files.
|
||||
Нужен механизм «результат = набор файлов» (zip?).
|
||||
- **Мультифайловый вывод** — механизм «результат = набор файлов» (zip)
|
||||
реализован Остальные 1→many: gif-to-frames, separate-colors,
|
||||
multiply-grid-as-files — подключаются по мере нужды.
|
||||
- **Анимационные эффекты** — slow-reveal, fade-in/out, disappearing, scrolling:
|
||||
это видео/GIF на выходе, а не PNG. Отдельное решение о формате результата.
|
||||
- **HARD-хвост** — glitch-art, extract-signature, handwritten→digital,
|
||||
|
||||
@@ -9,7 +9,8 @@
|
||||
"build": "pnpm --dir web build",
|
||||
"format": "pnpm format:docs && pnpm --dir web format",
|
||||
"format:docs": "prettier --write docs --log-level warn",
|
||||
"check:docs": "prettier --check docs"
|
||||
"check:docs": "prettier --check docs",
|
||||
"generate:fixtures": "node tests/fixtures/manual-edge-cases/generate.mjs"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "Ku6epXBOCTuK",
|
||||
|
||||
|
After Width: | Height: | Size: 9.0 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 162 KiB |
|
After Width: | Height: | Size: 68 B |
|
After Width: | Height: | Size: 70 B |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 79 B |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 225 B |
|
After Width: | Height: | Size: 96 B |
|
After Width: | Height: | Size: 203 B |
|
After Width: | Height: | Size: 290 B |
|
After Width: | Height: | Size: 601 B |
|
After Width: | Height: | Size: 73 B |
@@ -0,0 +1 @@
|
||||
this is definitely not a png file
|
||||
@@ -0,0 +1,329 @@
|
||||
import { deflateSync } from "node:zlib";
|
||||
import { writeFileSync, mkdirSync } from "node:fs";
|
||||
import { join, dirname } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
const CRC_TABLE = (() => {
|
||||
const table = new Uint32Array(256);
|
||||
for (let n = 0; n < 256; n++) {
|
||||
let c = n;
|
||||
for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
|
||||
table[n] = c >>> 0;
|
||||
}
|
||||
return table;
|
||||
})();
|
||||
|
||||
function crc32(data) {
|
||||
let c = 0xffffffff;
|
||||
for (let i = 0; i < data.length; i++)
|
||||
c = CRC_TABLE[(c ^ data[i]) & 0xff] ^ (c >>> 8);
|
||||
return (c ^ 0xffffffff) >>> 0;
|
||||
}
|
||||
|
||||
function chunk(type, data) {
|
||||
const out = Buffer.alloc(8 + data.length + 4);
|
||||
out.writeUInt32BE(data.length, 0);
|
||||
out.write(type, 4, "ascii");
|
||||
data.copy(out, 8);
|
||||
out.writeUInt32BE(crc32(out.subarray(4, 8 + data.length)), 8 + data.length);
|
||||
return out;
|
||||
}
|
||||
|
||||
function makePng(width, height, pixelAt) {
|
||||
const sig = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
|
||||
const ihdr = Buffer.alloc(13);
|
||||
ihdr.writeUInt32BE(width, 0);
|
||||
ihdr.writeUInt32BE(height, 4);
|
||||
ihdr[8] = 8; // bit depth
|
||||
ihdr[9] = 6; // color type RGBA
|
||||
ihdr[10] = 0; // compression
|
||||
ihdr[11] = 0; // filter
|
||||
ihdr[12] = 0; // interlace
|
||||
const stride = width * 4 + 1;
|
||||
const raw = Buffer.alloc(height * stride);
|
||||
for (let y = 0; y < height; y++) {
|
||||
raw[y * stride] = 0; // filter: none
|
||||
for (let x = 0; x < width; x++) {
|
||||
const [r, g, b, a] = pixelAt(x, y);
|
||||
const p = y * stride + 1 + x * 4;
|
||||
raw[p] = r;
|
||||
raw[p + 1] = g;
|
||||
raw[p + 2] = b;
|
||||
raw[p + 3] = a;
|
||||
}
|
||||
}
|
||||
return Buffer.concat([
|
||||
sig,
|
||||
chunk("IHDR", ihdr),
|
||||
chunk("IDAT", deflateSync(raw)),
|
||||
chunk("IEND", Buffer.alloc(0)),
|
||||
]);
|
||||
}
|
||||
|
||||
function makePalettePng(width, height, palette, pixelAt) {
|
||||
const sig = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
|
||||
const ihdr = Buffer.alloc(13);
|
||||
ihdr.writeUInt32BE(width, 0);
|
||||
ihdr.writeUInt32BE(height, 4);
|
||||
ihdr[8] = 8; // bit depth
|
||||
ihdr[9] = 3; // color type: indexed
|
||||
ihdr[10] = 0;
|
||||
ihdr[11] = 0;
|
||||
ihdr[12] = 0;
|
||||
|
||||
// PLTE: palette entries (R,G,B × count), padded to multiple of 3
|
||||
const plteData = Buffer.alloc(256 * 3);
|
||||
for (let i = 0; i < palette.length && i < 256; i++) {
|
||||
plteData[i * 3] = palette[i][0];
|
||||
plteData[i * 3 + 1] = palette[i][1];
|
||||
plteData[i * 3 + 2] = palette[i][2];
|
||||
}
|
||||
|
||||
// Raw image data: 1 byte per pixel (index)
|
||||
const stride = width + 1;
|
||||
const raw = Buffer.alloc(height * stride);
|
||||
for (let y = 0; y < height; y++) {
|
||||
raw[y * stride] = 0;
|
||||
for (let x = 0; x < width; x++) {
|
||||
raw[y * stride + 1 + x] = pixelAt(x, y);
|
||||
}
|
||||
}
|
||||
|
||||
return Buffer.concat([
|
||||
sig,
|
||||
chunk("IHDR", ihdr),
|
||||
chunk("PLTE", plteData),
|
||||
chunk("IDAT", deflateSync(raw)),
|
||||
chunk("IEND", Buffer.alloc(0)),
|
||||
]);
|
||||
}
|
||||
|
||||
function make16bitPng(width, height, pixelAt) {
|
||||
const sig = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
|
||||
const ihdr = Buffer.alloc(13);
|
||||
ihdr.writeUInt32BE(width, 0);
|
||||
ihdr.writeUInt32BE(height, 4);
|
||||
ihdr[8] = 16; // bit depth
|
||||
ihdr[9] = 6; // color type RGBA
|
||||
ihdr[10] = 0;
|
||||
ihdr[11] = 0;
|
||||
ihdr[12] = 0;
|
||||
|
||||
const stride = width * 8 + 1;
|
||||
const raw = Buffer.alloc(height * stride);
|
||||
for (let y = 0; y < height; y++) {
|
||||
raw[y * stride] = 0;
|
||||
for (let x = 0; x < width; x++) {
|
||||
const [r, g, b, a] = pixelAt(x, y);
|
||||
const p = y * stride + 1 + x * 8;
|
||||
raw.writeUInt16BE(r, p);
|
||||
raw.writeUInt16BE(g, p + 2);
|
||||
raw.writeUInt16BE(b, p + 4);
|
||||
raw.writeUInt16BE(a, p + 6);
|
||||
}
|
||||
}
|
||||
|
||||
return Buffer.concat([
|
||||
sig,
|
||||
chunk("IHDR", ihdr),
|
||||
chunk("IDAT", deflateSync(raw)),
|
||||
chunk("IEND", Buffer.alloc(0)),
|
||||
]);
|
||||
}
|
||||
|
||||
function makeCorruptPng() {
|
||||
const sig = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
|
||||
const ihdr = Buffer.alloc(13);
|
||||
ihdr.writeUInt32BE(4, 0);
|
||||
ihdr.writeUInt32BE(4, 4);
|
||||
ihdr[8] = 8;
|
||||
ihdr[9] = 2; // RGB
|
||||
ihdr[10] = 0;
|
||||
ihdr[11] = 0;
|
||||
ihdr[12] = 0;
|
||||
|
||||
const stride = 4 * 3 + 1;
|
||||
const raw = Buffer.alloc(4 * stride);
|
||||
for (let y = 0; y < 4; y++) {
|
||||
raw[y * stride] = 0;
|
||||
for (let x = 0; x < 4; x++) {
|
||||
const p = y * stride + 1 + x * 3;
|
||||
raw[p] = 255;
|
||||
raw[p + 1] = 0;
|
||||
raw[p + 2] = 0;
|
||||
}
|
||||
}
|
||||
|
||||
const idat = chunk("IDAT", deflateSync(raw));
|
||||
// Corrupt CRC: flip a byte in the CRC field
|
||||
idat[idat.length - 1] ^= 0xff;
|
||||
|
||||
return Buffer.concat([sig, chunk("IHDR", ihdr), idat, chunk("IEND", Buffer.alloc(0))]);
|
||||
}
|
||||
|
||||
function makeTruncatedPng() {
|
||||
return Buffer.from([137, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 13, 73, 72, 68, 82]);
|
||||
}
|
||||
|
||||
const outDir = join(__dirname);
|
||||
|
||||
// 1. PNG 1×1
|
||||
writeFileSync(join(outDir, "1x1.png"), makePng(1, 1, () => [255, 0, 0, 255]));
|
||||
|
||||
// 2. PNG 1×1 fully transparent
|
||||
writeFileSync(
|
||||
join(outDir, "1x1-transparent.png"),
|
||||
makePng(1, 1, () => [0, 0, 0, 0]),
|
||||
);
|
||||
|
||||
// 3. Large 1024×1024 checkerboard (≈1 megapixel RGBA)
|
||||
writeFileSync(
|
||||
join(outDir, "1024x1024-checker.png"),
|
||||
makePng(1024, 1024, (x, y) =>
|
||||
x % 2 === y % 2 ? [255, 0, 0, 255] : [255, 255, 255, 255],
|
||||
),
|
||||
);
|
||||
|
||||
// 4. 1920×1080 gradient (≈2 megapixels, tests larger displays)
|
||||
writeFileSync(
|
||||
join(outDir, "1920x1080-gradient.png"),
|
||||
makePng(1920, 1080, (x, y) => [
|
||||
Math.floor((x / 1920) * 255),
|
||||
Math.floor((y / 1080) * 255),
|
||||
128,
|
||||
255,
|
||||
]),
|
||||
);
|
||||
|
||||
// 5. Transparency checkerboard (black/white with alpha pattern)
|
||||
writeFileSync(
|
||||
join(outDir, "alpha-checkerboard.png"),
|
||||
makePng(64, 64, (x, y) => {
|
||||
const alpha = (x + y) % 3 === 0 ? 0 : 255;
|
||||
const base = (x % 2 === y % 2) ? 0 : 255;
|
||||
return [base, base, base, alpha];
|
||||
}),
|
||||
);
|
||||
|
||||
// 6. Palette / indexed-color PNG (4 colors: red, green, blue, white)
|
||||
const palette = [
|
||||
[255, 0, 0],
|
||||
[0, 255, 0],
|
||||
[0, 0, 255],
|
||||
[255, 255, 255],
|
||||
];
|
||||
writeFileSync(
|
||||
join(outDir, "palette-4colors.png"),
|
||||
makePalettePng(32, 32, palette, (x, y) => {
|
||||
const idx = (Math.floor(x / 8) + Math.floor(y / 8)) % palette.length;
|
||||
return idx;
|
||||
}),
|
||||
);
|
||||
|
||||
// 7. Palette with many colors (256-color full palette)
|
||||
const fullPalette = Array.from({ length: 256 }, (_, i) => [
|
||||
i,
|
||||
(i * 3) % 256,
|
||||
(i * 7) % 256,
|
||||
]);
|
||||
writeFileSync(
|
||||
join(outDir, "palette-256.png"),
|
||||
makePalettePng(64, 64, fullPalette, (x, y) => (x + y * 64) % 256),
|
||||
);
|
||||
|
||||
// 8. 16-bit RGBA PNG (subtle gradient to test high bit depth)
|
||||
writeFileSync(
|
||||
join(outDir, "16bit-subtle.png"),
|
||||
make16bitPng(32, 32, (x, y) => [
|
||||
Math.floor((x / 32) * 65535),
|
||||
Math.floor((y / 32) * 65535),
|
||||
32768,
|
||||
65535,
|
||||
]),
|
||||
);
|
||||
|
||||
// 9. Corrupt PNG (bad CRC in IDAT)
|
||||
writeFileSync(join(outDir, "corrupt-bad-crc.png"), makeCorruptPng());
|
||||
|
||||
// 10. Truncated PNG (only header, no data)
|
||||
writeFileSync(join(outDir, "truncated.png"), makeTruncatedPng());
|
||||
|
||||
// 11. Non-PNG file disguised as PNG
|
||||
writeFileSync(
|
||||
join(outDir, "fake-png.txt"),
|
||||
Buffer.from("this is definitely not a png file"),
|
||||
);
|
||||
|
||||
// 12. All-black fully opaque
|
||||
writeFileSync(
|
||||
join(outDir, "all-black-opaque.png"),
|
||||
makePng(64, 64, () => [0, 0, 0, 255]),
|
||||
);
|
||||
|
||||
// 13. All-white fully opaque
|
||||
writeFileSync(
|
||||
join(outDir, "all-white-opaque.png"),
|
||||
makePng(64, 64, () => [255, 255, 255, 255]),
|
||||
);
|
||||
|
||||
// 14. All-transparent
|
||||
writeFileSync(
|
||||
join(outDir, "all-transparent.png"),
|
||||
makePng(64, 64, () => [0, 0, 0, 0]),
|
||||
);
|
||||
|
||||
// 15. Semi-transparent gradient (alpha 0→255 across width)
|
||||
writeFileSync(
|
||||
join(outDir, "alpha-gradient.png"),
|
||||
makePng(128, 64, (x, y) => [
|
||||
0,
|
||||
100,
|
||||
255,
|
||||
Math.floor((x / 128) * 255),
|
||||
]),
|
||||
);
|
||||
|
||||
// 16. Noise pattern (random-ish pixels)
|
||||
writeFileSync(
|
||||
join(outDir, "noise-pattern.png"),
|
||||
makePng(100, 100, (x, y) => {
|
||||
const v = ((x * 7919 + y * 104729) % 256);
|
||||
return [v, (v * 3) % 256, (v * 7) % 256, 255];
|
||||
}),
|
||||
);
|
||||
|
||||
// 17. Minimal size: 2×2 with extreme colors
|
||||
writeFileSync(
|
||||
join(outDir, "2x2-extreme.png"),
|
||||
makePng(2, 2, (x, y) => {
|
||||
if (x === 0 && y === 0) return [0, 0, 0, 255];
|
||||
if (x === 1 && y === 0) return [255, 255, 255, 255];
|
||||
if (x === 0 && y === 1) return [255, 0, 0, 128];
|
||||
return [0, 0, 255, 0];
|
||||
}),
|
||||
);
|
||||
|
||||
// 18. Long strip (1×4000) — tests scroll / vertical tools
|
||||
writeFileSync(
|
||||
join(outDir, "1x4000-strip.png"),
|
||||
makePng(1, 4000, (x, y) => [y % 256, (y * 2) % 256, (y * 3) % 256, 255]),
|
||||
);
|
||||
|
||||
// 19. Wide strip (4000×1) — tests horizontal handling
|
||||
writeFileSync(
|
||||
join(outDir, "4000x1-strip.png"),
|
||||
makePng(4000, 1, (x, y) => [x % 256, (x * 2) % 256, (x * 3) % 256, 255]),
|
||||
);
|
||||
|
||||
// 20. PNG with very high alpha variance (sparse transparency)
|
||||
writeFileSync(
|
||||
join(outDir, "sparse-alpha.png"),
|
||||
makePng(100, 100, (x, y) => {
|
||||
const sparse = ((x * 31 + y * 17) % 100) < 10;
|
||||
return [sparse ? 0 : 100, sparse ? 0 : 200, 255, sparse ? 0 : 255];
|
||||
}),
|
||||
);
|
||||
|
||||
console.log("Generated 20 edge case PNG files in:", outDir);
|
||||
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 879 B |
|
After Width: | Height: | Size: 722 B |
|
After Width: | Height: | Size: 16 B |
@@ -8,15 +8,15 @@
|
||||
// (see __tests__/helpers.ts). The fixture dictionary has exactly five
|
||||
// tokens; anything else must be reported even if it exists in the REAL
|
||||
// web/src/app.css.
|
||||
import { RuleTester, Linter } from "eslint";
|
||||
import { RuleTester } from "eslint";
|
||||
import svelteParser from "svelte-eslint-parser";
|
||||
import tseslint from "typescript-eslint";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import noHardcoded from "../design-tokens/no-hardcoded-in-svelte.js";
|
||||
import noCategoryMismatch from "../design-tokens/no-category-mismatch.js";
|
||||
import noHardcoded from "../design-tokens/no-hardcoded-in-svelte.js";
|
||||
import noTokenDefinition from "../design-tokens/no-token-definition-in-svelte.js";
|
||||
import noUndefined from "../design-tokens/no-undefined-in-svelte.js";
|
||||
import { verifyInFixtures, type FlatConfig } from "./helpers.js";
|
||||
import { asRuleModule, verifyInFixtures, type FlatConfig } from "./helpers.js";
|
||||
|
||||
const parserOptions = {
|
||||
parser: tseslint.parser,
|
||||
@@ -45,7 +45,7 @@ const frame = (style: string) => ({
|
||||
});
|
||||
|
||||
describe("design-tokens/no-hardcoded-in-svelte", () => {
|
||||
ruleTester.run("no-hardcoded-in-svelte", noHardcoded, {
|
||||
ruleTester.run("no-hardcoded-in-svelte", asRuleModule(noHardcoded), {
|
||||
valid: [
|
||||
frame(".box { color: var(--color-fg); }"),
|
||||
frame(".box { padding: var(--space-3); }"),
|
||||
@@ -97,7 +97,7 @@ describe("design-tokens/no-hardcoded-in-svelte", () => {
|
||||
});
|
||||
|
||||
describe("design-tokens/no-category-mismatch", () => {
|
||||
ruleTester.run("no-category-mismatch", noCategoryMismatch, {
|
||||
ruleTester.run("no-category-mismatch", asRuleModule(noCategoryMismatch), {
|
||||
valid: [
|
||||
frame(".box { padding: var(--space-1); }"),
|
||||
frame(".box { color: var(--color-fg); }"),
|
||||
@@ -117,7 +117,10 @@ describe("design-tokens/no-category-mismatch", () => {
|
||||
});
|
||||
|
||||
describe("design-tokens/no-token-definition-in-svelte", () => {
|
||||
ruleTester.run("no-token-definition-in-svelte", noTokenDefinition, {
|
||||
ruleTester.run(
|
||||
"no-token-definition-in-svelte",
|
||||
asRuleModule(noTokenDefinition),
|
||||
{
|
||||
valid: [
|
||||
frame(".box { --local: var(--color-fg); }"),
|
||||
frame(".box { --local: calc(var(--space-1) * 2); }"),
|
||||
@@ -137,7 +140,8 @@ describe("design-tokens/no-token-definition-in-svelte", () => {
|
||||
errors: [{ messageId: "tokenPrimitive" }],
|
||||
},
|
||||
],
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("design-tokens/no-undefined-in-svelte", () => {
|
||||
@@ -145,7 +149,9 @@ describe("design-tokens/no-undefined-in-svelte", () => {
|
||||
{
|
||||
files: ["**/*.svelte"],
|
||||
plugins: {
|
||||
"design-tokens": { rules: { "no-undefined-in-svelte": noUndefined } },
|
||||
"design-tokens": {
|
||||
rules: { "no-undefined-in-svelte": asRuleModule(noUndefined) },
|
||||
},
|
||||
},
|
||||
rules: { "design-tokens/no-undefined-in-svelte": "error" },
|
||||
languageOptions: {
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
// `verifyInFixtures` runs a rule with cwd fixed to `__fixtures__/`, so both
|
||||
// work without touching process.cwd() or the real src/ tree.
|
||||
import { Linter } from "eslint";
|
||||
import type { Rule } from "eslint";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
@@ -32,3 +33,18 @@ export function verifyInFixtures(
|
||||
const linter = new Linter({ configType: "flat", cwd: FIXTURES_DIR });
|
||||
return linter.verify(code, config, path.join(FIXTURES_SRC, relFile));
|
||||
}
|
||||
|
||||
/** Wrap a script body into a minimal Svelte component (runs in runes mode). */
|
||||
export function svelteComponent(script: string): string {
|
||||
return `<main>Hello</main>\n\n<script lang="ts">\n${script}\n</script>\n`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Narrow an inferred JS rule to the typed `Rule.RuleModule`. The plugin rule
|
||||
* files are plain JS, so TypeScript infers a widened shape (`meta.type: string`)
|
||||
* that `RuleTester.run` rejects; the shape is structurally correct at runtime,
|
||||
* which is why existing rules keep their JS inference and only the tests cast.
|
||||
*/
|
||||
export function asRuleModule(rule: unknown): Rule.RuleModule {
|
||||
return rule as Rule.RuleModule;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
// Tests for the conventions/interface-props rule
|
||||
// (web/eslint-plugins/conventions/interface-props.js).
|
||||
//
|
||||
// The rule is a pure AST check (no filesystem), so it runs through the standard
|
||||
// RuleTester with svelte-eslint-parser + the TS sub-parser. No fixtures needed.
|
||||
import { RuleTester } from "eslint";
|
||||
import svelteParser from "svelte-eslint-parser";
|
||||
import tseslint from "typescript-eslint";
|
||||
import { describe, it } from "vitest";
|
||||
import interfaceProps from "../conventions/interface-props.js";
|
||||
import { asRuleModule, svelteComponent } from "./helpers.js";
|
||||
|
||||
RuleTester.describe = describe;
|
||||
RuleTester.it = it;
|
||||
|
||||
const ruleTester = new RuleTester({
|
||||
languageOptions: {
|
||||
parser: svelteParser,
|
||||
parserOptions: { parser: tseslint.parser },
|
||||
},
|
||||
});
|
||||
|
||||
const frame = (script: string) => ({
|
||||
code: svelteComponent(script),
|
||||
filename: "Component.svelte",
|
||||
});
|
||||
|
||||
ruleTester.run("interface-props", asRuleModule(interfaceProps), {
|
||||
valid: [
|
||||
{ code: "<main>Hello</main>", filename: "Component.svelte" },
|
||||
frame("let x = 1;"),
|
||||
// $state / $derived calls are unrelated, not props.
|
||||
frame("const s = $state(0);"),
|
||||
frame("const doubled = $derived(s * 2);"),
|
||||
frame(
|
||||
"interface Props { label: string }\nlet { label }: Props = $props();",
|
||||
),
|
||||
frame(
|
||||
"interface Props { label: string }\nlet { label = 'x' }: Props = $props();",
|
||||
),
|
||||
frame(
|
||||
"interface Props { value: string }\nlet { value = $bindable() }: Props = $props();",
|
||||
),
|
||||
// Interface declared after the destructuring is still fine.
|
||||
frame(
|
||||
"let { label }: Props = $props();\ninterface Props { label: string }",
|
||||
),
|
||||
frame(
|
||||
"interface Props { id: string; rest?: unknown }\nlet { id, ...rest }: Props = $props();",
|
||||
),
|
||||
frame(
|
||||
"import type { Snippet } from 'svelte';\n" +
|
||||
"interface Props { children?: Snippet }\n" +
|
||||
"let { children }: Props = $props();",
|
||||
),
|
||||
],
|
||||
invalid: [
|
||||
{
|
||||
// Untyped destructuring with a local interface -> autofixable.
|
||||
...frame("interface Props { label: string }\nlet { label } = $props();"),
|
||||
output: svelteComponent(
|
||||
"interface Props { label: string }\nlet { label }: Props = $props();",
|
||||
),
|
||||
errors: [{ messageId: "untypedDestructure" }],
|
||||
},
|
||||
{
|
||||
// Untyped destructuring without an interface -> report, no fix.
|
||||
...frame("let { label } = $props();"),
|
||||
errors: [{ messageId: "untypedDestructure" }],
|
||||
},
|
||||
{
|
||||
// Binding the whole props object instead of destructuring.
|
||||
...frame("let props = $props();"),
|
||||
errors: [{ messageId: "noDestructure" }],
|
||||
},
|
||||
{
|
||||
// Inline generic is banned outright.
|
||||
...frame("const props = $props<{ a: string }>();"),
|
||||
errors: [{ messageId: "inlineGeneric" }],
|
||||
},
|
||||
{
|
||||
// A different type name than the local interface Props.
|
||||
...frame(
|
||||
"interface Props { a: string }\nlet { a }: StageProps = $props();",
|
||||
),
|
||||
errors: [{ messageId: "notNamedProps" }],
|
||||
},
|
||||
{
|
||||
// Inline object props type instead of interface Props.
|
||||
...frame("let { a }: { a: string } = $props();"),
|
||||
errors: [{ messageId: "inlineObjectType" }],
|
||||
},
|
||||
{
|
||||
// Reference to Props without any local declaration.
|
||||
...frame("let { a }: Props = $props();"),
|
||||
errors: [{ messageId: "missingInterface" }],
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -11,12 +11,16 @@
|
||||
// shared-> everything else: core/, i18n/, theme, ...
|
||||
import { describe, expect, it } from "vitest";
|
||||
import noMixedImports from "../isolation/no-mixed-imports.js";
|
||||
import { verifyInFixtures, type FlatConfig } from "./helpers.js";
|
||||
import { asRuleModule, verifyInFixtures, type FlatConfig } from "./helpers.js";
|
||||
|
||||
const isolationConfig: FlatConfig = [
|
||||
{
|
||||
files: ["**/*.{ts,svelte}"],
|
||||
plugins: { isolation: { rules: { "no-mixed-imports": noMixedImports } } },
|
||||
plugins: {
|
||||
isolation: {
|
||||
rules: { "no-mixed-imports": asRuleModule(noMixedImports) },
|
||||
},
|
||||
},
|
||||
rules: { "isolation/no-mixed-imports": "error" },
|
||||
},
|
||||
];
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
// Tests for the conventions/no-string-union-alias rule
|
||||
// (web/eslint-plugins/conventions/no-string-union-alias.js).
|
||||
//
|
||||
// The rule is a pure AST check (no filesystem), so it runs through the standard
|
||||
// RuleTester: plain .ts via the tseslint parser, and (because the rule is also
|
||||
// enabled on new .svelte scripts) .svelte via the svelte tester. No fixtures.
|
||||
import { RuleTester } from "eslint";
|
||||
import svelteParser from "svelte-eslint-parser";
|
||||
import tseslint from "typescript-eslint";
|
||||
import { describe, it } from "vitest";
|
||||
import noStringUnionAlias from "../conventions/no-string-union-alias.js";
|
||||
import { asRuleModule, svelteComponent } from "./helpers.js";
|
||||
|
||||
RuleTester.describe = describe;
|
||||
RuleTester.it = it;
|
||||
|
||||
const tsTester = new RuleTester({
|
||||
languageOptions: {
|
||||
parser: tseslint.parser,
|
||||
},
|
||||
});
|
||||
|
||||
const svelteTester = new RuleTester({
|
||||
languageOptions: {
|
||||
parser: svelteParser,
|
||||
parserOptions: { parser: tseslint.parser },
|
||||
},
|
||||
});
|
||||
|
||||
const frame = (script: string) => ({
|
||||
code: svelteComponent(script),
|
||||
filename: "Component.svelte",
|
||||
});
|
||||
|
||||
tsTester.run("no-string-union-alias", asRuleModule(noStringUnionAlias), {
|
||||
valid: [
|
||||
{ code: "type X = 1 | 2;", filename: "types.ts" },
|
||||
{ code: "type X = 'a' | number;", filename: "types.ts" },
|
||||
{ code: "type X = string;", filename: "types.ts" },
|
||||
{ code: "const KIND = { a: 1, b: 2 } as const;", filename: "types.ts" },
|
||||
{ code: "function f(x: 'a' | 'b') {}", filename: "types.ts" },
|
||||
{ code: "const x: 'a' | 'b' = 'a';", filename: "types.ts" },
|
||||
{ code: "type X = 'a' | TemplateStrings;", filename: "types.ts" },
|
||||
],
|
||||
invalid: [
|
||||
{
|
||||
code: "type Kind = 'a' | 'b' | 'c';",
|
||||
filename: "types.ts",
|
||||
errors: [{ messageId: "stringUnion" }],
|
||||
},
|
||||
{
|
||||
// Nested/parenthesized unions still resolve to string literals.
|
||||
code: "type Kind = ('a' | 'b') | 'c';",
|
||||
filename: "types.ts",
|
||||
errors: [{ messageId: "stringUnion" }],
|
||||
},
|
||||
{
|
||||
code: "type Lang =\n\t'en'\n\t| 'ru';",
|
||||
filename: "types.ts",
|
||||
errors: [{ messageId: "stringUnion" }],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// The rule also runs on new .svelte files (their scripts are TS).
|
||||
svelteTester.run(
|
||||
"no-string-union-alias (svelte script)",
|
||||
asRuleModule(noStringUnionAlias),
|
||||
{
|
||||
valid: [frame("type X = 1 | 2;"), frame("const M = { a: 1 } as const;")],
|
||||
invalid: [
|
||||
{
|
||||
...frame("type Kind = 'a' | 'b' | 'c';"),
|
||||
errors: [{ messageId: "stringUnion" }],
|
||||
},
|
||||
],
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* Local ESLint plugin "conventions".
|
||||
*
|
||||
* Cross-cutting code conventions that the recommended rule sets don't enforce
|
||||
* (and plain @typescript-eslint rules cannot express in one shot):
|
||||
*
|
||||
* - interface-props: Svelte 5 props always go through a local `interface
|
||||
* Props` + `let { ... }: Props = $props()` (no inline generics, no inline
|
||||
* type imports, no untyped destructuring);
|
||||
* - no-string-union-alias: string-literal union aliases (`type Kind = 'a' |
|
||||
* 'b'`) are banned in favor of a single `as const` object + indexed access,
|
||||
* so literal sets live in exactly one place.
|
||||
*
|
||||
* Rules are AST-only (no filesystem), see the tests in eslint-plugins/__tests__.
|
||||
*/
|
||||
import interfaceProps from "./interface-props.js";
|
||||
import noStringUnionAlias from "./no-string-union-alias.js";
|
||||
|
||||
export default {
|
||||
meta: {
|
||||
name: "conventions",
|
||||
version: "0.1.0",
|
||||
},
|
||||
rules: {
|
||||
"interface-props": interfaceProps,
|
||||
"no-string-union-alias": noStringUnionAlias,
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,135 @@
|
||||
// Rule: SVELTE 5 PROPS MUST USE A LOCAL `interface Props`.
|
||||
//
|
||||
// Convention (AGENTS.md): every typed props of a component is described by a
|
||||
// local `interface Props`, and the props are destructured with the annotation:
|
||||
//
|
||||
// interface Props {
|
||||
// label: string;
|
||||
// accent?: boolean;
|
||||
// children?: Snippet;
|
||||
// }
|
||||
// let { label, accent = false, children }: Props = $props();
|
||||
//
|
||||
// Banned instead:
|
||||
// - the inline generic `$props<{ ... }>()` — hard to read and splits the
|
||||
// type away from the file structure;
|
||||
// - untyped destructuring `let { ... } = $props()`;
|
||||
// - binding the whole props object (`const props = $props()`);
|
||||
// - any props type name other than the local `interface Props`.
|
||||
//
|
||||
// Inline `import('...')` type queries are NOT checked here: they are already
|
||||
// banned by @typescript-eslint/consistent-type-imports.
|
||||
//
|
||||
// The check is purely syntactic (AST-level). Svelte's runes model guarantees
|
||||
// $props() only exists in instance <script> blocks, so no scope/type info is
|
||||
// needed. Interfaces are collected from every <script> (module + instance).
|
||||
|
||||
import { isInsideScriptElement } from "./utils.js";
|
||||
|
||||
export default {
|
||||
meta: {
|
||||
type: "suggestion",
|
||||
fixable: "code",
|
||||
docs: {
|
||||
description:
|
||||
"Require a local `interface Props` and `let { ... }: Props = $props()` for Svelte 5 props; ban inline generics, untyped destructuring and non-local props type names.",
|
||||
category: "Svelte conventions",
|
||||
recommended: true,
|
||||
},
|
||||
schema: [],
|
||||
messages: {
|
||||
inlineGeneric:
|
||||
"Avoid the inline generic `$props<T>()`; declare a local `interface Props` and destructure it: `let { ... }: Props = $props()`.",
|
||||
untypedDestructure:
|
||||
"Annotate the props destructuring with a local `interface Props`: `let { ... }: Props = $props()`.",
|
||||
noDestructure:
|
||||
"Destructure props with `let { ... }: Props = $props()` instead of binding the whole `$props()` object.",
|
||||
notNamedProps:
|
||||
"The props type must be the local `interface Props` (found '{{name}}').",
|
||||
inlineObjectType:
|
||||
"Declare a local `interface Props` instead of an inline object props type.",
|
||||
missingInterface:
|
||||
"No local `interface Props` is declared in this component; add one and use it as the props type.",
|
||||
},
|
||||
},
|
||||
create(context) {
|
||||
const interfaces = new Set();
|
||||
|
||||
return {
|
||||
Program(node) {
|
||||
for (const child of node.body) {
|
||||
if (child.type !== "SvelteScriptElement") continue;
|
||||
for (const stmt of child.body ?? []) {
|
||||
if (stmt.type !== "TSInterfaceDeclaration") continue;
|
||||
interfaces.add(stmt.id.name);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
CallExpression(node) {
|
||||
if (
|
||||
node.callee.type !== "Identifier" ||
|
||||
node.callee.name !== "$props"
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (!isInsideScriptElement(node)) return;
|
||||
|
||||
// Inline generic $props<{ ... }>() is banned outright.
|
||||
if (node.typeArguments?.params?.length) {
|
||||
context.report({ node: node.callee, messageId: "inlineGeneric" });
|
||||
return;
|
||||
}
|
||||
|
||||
let declarator = node.parent;
|
||||
while (declarator && declarator.type !== "VariableDeclarator") {
|
||||
declarator = declarator.parent;
|
||||
}
|
||||
if (!declarator) return;
|
||||
const id = declarator.id;
|
||||
|
||||
if (id.type === "ObjectPattern") {
|
||||
const annotation = id.typeAnnotation?.typeAnnotation ?? null;
|
||||
if (annotation) {
|
||||
if (annotation.type === "TSTypeReference") {
|
||||
const typeName = annotation.typeName;
|
||||
if (!typeName || typeName.type !== "Identifier") return;
|
||||
if (typeName.name !== "Props") {
|
||||
context.report({
|
||||
node: typeName,
|
||||
messageId: "notNamedProps",
|
||||
data: { name: typeName.name },
|
||||
});
|
||||
} else if (!interfaces.has("Props")) {
|
||||
context.report({
|
||||
node: typeName,
|
||||
messageId: "missingInterface",
|
||||
});
|
||||
}
|
||||
} else if (annotation.type !== "TSImportType") {
|
||||
context.report({
|
||||
node: annotation,
|
||||
messageId: "inlineObjectType",
|
||||
});
|
||||
}
|
||||
} else {
|
||||
context.report({
|
||||
node: id,
|
||||
messageId: "untypedDestructure",
|
||||
...(interfaces.has("Props")
|
||||
? {
|
||||
fix: (fixer) => fixer.insertTextAfter(id, ": Props"),
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (id.type === "Identifier") {
|
||||
context.report({ node: id, messageId: "noDestructure" });
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,64 @@
|
||||
// Rule: NO STRING-LITERAL UNION TYPE ALIASES.
|
||||
//
|
||||
// A type alias whose members are all string literals (`type Kind = 'a' | 'b'`)
|
||||
// duplicates the literal set: the same set is re-typed in many places, and new
|
||||
// "derived" aliases (`type Kind2 = 'a' | 'c'`) appear that drift from the
|
||||
// source. Prefer a single `as const` object as the source of truth and derive
|
||||
// the type from it with indexed access.
|
||||
//
|
||||
// const KIND = { a: ..., b: ..., c: ... } as const;
|
||||
// type Kind = (typeof KIND)[keyof typeof KIND];
|
||||
//
|
||||
// Only TSTypeAliasDeclaration is checked — inline unions in parameter or
|
||||
// property types (one-off uses) are left alone.
|
||||
|
||||
/** True when the union and all its (possibly nested) members are string literals. */
|
||||
function allStringLiterals(/** @type {any} */ union) {
|
||||
for (const member of union.types) {
|
||||
if (member.type === "TSUnionType") {
|
||||
if (!allStringLiterals(member)) return false;
|
||||
} else if (member.type === "TSLiteralType") {
|
||||
const literal = member.literal;
|
||||
if (!(literal.type === "Literal" && typeof literal.value === "string")) {
|
||||
return false;
|
||||
}
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export default {
|
||||
meta: {
|
||||
type: "suggestion",
|
||||
docs: {
|
||||
description:
|
||||
"Ban string-literal union type aliases in favor of an `as const` object plus `(typeof X)[keyof typeof X]`.",
|
||||
category: "TypeScript conventions",
|
||||
recommended: true,
|
||||
},
|
||||
schema: [],
|
||||
messages: {
|
||||
stringUnion:
|
||||
"Prefer one `as const` object over the string-literal union alias '{{name}}' — literal sets duplicated across types or files drift apart. Derive the type instead: `const {{constName}} = {...} as const; type {{name}} = (typeof {{constName}})[keyof typeof {{constName}}]`.",
|
||||
},
|
||||
},
|
||||
create(context) {
|
||||
return {
|
||||
TSTypeAliasDeclaration(node) {
|
||||
const annotation = node.typeAnnotation;
|
||||
if (!annotation || annotation.type !== "TSUnionType") return;
|
||||
if (!allStringLiterals(annotation)) return;
|
||||
context.report({
|
||||
node: node.id,
|
||||
messageId: "stringUnion",
|
||||
data: {
|
||||
name: node.id.name,
|
||||
constName: node.id.name.toUpperCase(),
|
||||
},
|
||||
});
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,15 @@
|
||||
// Shared helpers for the conventions plugin (web/eslint-plugins/conventions).
|
||||
|
||||
/**
|
||||
* True when the node sits somewhere under a Svelte <script> block
|
||||
* (SvelteScriptElement). Used to limit $props() checks to script code.
|
||||
* @param {any} node
|
||||
*/
|
||||
export function isInsideScriptElement(node) {
|
||||
let cursor = node.parent;
|
||||
while (cursor && cursor.type !== "Program") {
|
||||
if (cursor.type === "SvelteScriptElement") return true;
|
||||
cursor = cursor.parent;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import svelteParser from "svelte-eslint-parser";
|
||||
import tseslint from "typescript-eslint";
|
||||
import designTokens from "./eslint-plugins/index.js";
|
||||
import isolationPlugin from "./eslint-plugins/isolation/index.js";
|
||||
import conventionsPlugin from "./eslint-plugins/conventions/index.js";
|
||||
|
||||
// FIXME: надо игнорировать старые файлы, после переноса пути новых компонентов включают старые
|
||||
// Новый код редизайна: к нему применяем полные recommended-наборы уже сейчас.
|
||||
@@ -76,6 +77,8 @@ export default tseslint.config(
|
||||
// через default-проект. Перечисляются точечно: `**` в
|
||||
// allowDefaultProject запрещён tseslint.
|
||||
"eslint-plugins/__tests__/design-tokens.test.ts",
|
||||
"eslint-plugins/__tests__/interface-props.test.ts",
|
||||
"eslint-plugins/__tests__/no-string-union-alias.test.ts",
|
||||
"eslint-plugins/__tests__/helpers.ts",
|
||||
"eslint-plugins/__tests__/no-mixed-imports.test.ts",
|
||||
"eslint-plugins/__fixtures__/src/lib/v1/old.ts",
|
||||
@@ -138,6 +141,31 @@ export default tseslint.config(
|
||||
"design-tokens/no-undefined-in-svelte": "error",
|
||||
},
|
||||
},
|
||||
// Конвенция Svelte 5: пропсы через локальный `interface Props` +
|
||||
// `let {...}: Props = $props()` (плагин conventions/interface-props).
|
||||
// Только Svelte-файлы нового кода (см. newSvelteFiles).
|
||||
{
|
||||
files: newSvelteFiles,
|
||||
ignores: oldSvelteFiles,
|
||||
plugins: {
|
||||
conventions: conventionsPlugin,
|
||||
},
|
||||
rules: {
|
||||
"conventions/interface-props": "error",
|
||||
},
|
||||
},
|
||||
// Запрет строковых union-алиасов в пользу `as const` объектов
|
||||
// (плагин conventions/no-string-union-alias). TS и Svelte-скрипты нового кода.
|
||||
{
|
||||
files: newCode,
|
||||
ignores: oldCode,
|
||||
plugins: {
|
||||
conventions: conventionsPlugin,
|
||||
},
|
||||
rules: {
|
||||
"conventions/no-string-union-alias": "error",
|
||||
},
|
||||
},
|
||||
// Полные recommended-наборы — только на новый код.
|
||||
...[
|
||||
...jsRecommended.map((cfg) => ({
|
||||
|
||||
@@ -50,6 +50,7 @@
|
||||
"dependencies": {
|
||||
"@fontsource/ibm-plex-mono": "^5.3.0",
|
||||
"@fontsource/ibm-plex-sans": "^5.3.0",
|
||||
"@lucide/svelte": "^1.33.0"
|
||||
"@lucide/svelte": "^1.33.0",
|
||||
"fflate": "^0.8.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,9 @@ importers:
|
||||
'@lucide/svelte':
|
||||
specifier: ^1.33.0
|
||||
version: 1.33.0(svelte@5.56.9(@typescript-eslint/types@8.67.0))
|
||||
fflate:
|
||||
specifier: ^0.8.3
|
||||
version: 0.8.3
|
||||
devDependencies:
|
||||
'@csstools/postcss-global-data':
|
||||
specifier: ^4.0.0
|
||||
@@ -821,6 +824,9 @@ packages:
|
||||
picomatch:
|
||||
optional: true
|
||||
|
||||
fflate@0.8.3:
|
||||
resolution: {integrity: sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==}
|
||||
|
||||
file-entry-cache@11.1.5:
|
||||
resolution: {integrity: sha512-+PFTHITI08JIGhnNpGNI8T8inUpgZfk3GNEqfT9R2zZV2iFXg3CvqzSl/uEhs7TSGujYRELEANyDvS8Fj7+S7Q==}
|
||||
|
||||
@@ -2265,6 +2271,8 @@ snapshots:
|
||||
optionalDependencies:
|
||||
picomatch: 4.0.5
|
||||
|
||||
fflate@0.8.3: {}
|
||||
|
||||
file-entry-cache@11.1.5:
|
||||
dependencies:
|
||||
flat-cache: 6.1.23
|
||||
|
||||
@@ -162,6 +162,7 @@
|
||||
--size-step-grip: 20px;
|
||||
--size-card-min: 240px;
|
||||
--size-card-min-narrow: 200px;
|
||||
--size-parts-grid-min: 120px;
|
||||
--size-workspace-min: 260px;
|
||||
--size-content-max: 880px;
|
||||
--size-content-max-wide: 1680px;
|
||||
|
||||
@@ -4,13 +4,14 @@
|
||||
import SchemaResultTile from "$lib/components/SchemaResultTile.svelte";
|
||||
import SchemaSourceTile from "$lib/components/SchemaSourceTile.svelte";
|
||||
import type { PixelImage } from "$lib/core/types";
|
||||
import type { InputMode, ResultKind } from "$lib/registry";
|
||||
import type { FileResult, InputMode, ResultKind } from "$lib/registry";
|
||||
|
||||
interface Props {
|
||||
inputMode: InputMode;
|
||||
resultKind?: ResultKind;
|
||||
source: PixelImage | null;
|
||||
result: PixelImage | null;
|
||||
fileResult?: FileResult | null;
|
||||
textSource?: string;
|
||||
textResult?: string | null;
|
||||
running?: boolean;
|
||||
@@ -28,6 +29,7 @@
|
||||
resultKind = "image",
|
||||
source,
|
||||
result,
|
||||
fileResult = null,
|
||||
textSource = "",
|
||||
textResult = null,
|
||||
running = false,
|
||||
@@ -56,10 +58,22 @@
|
||||
? result
|
||||
? `${result.width} × ${result.height} px`
|
||||
: "—"
|
||||
: resultKind === "files"
|
||||
? fileResult
|
||||
? `${fileResult.files.length} parts`
|
||||
: "—"
|
||||
: textResult
|
||||
? "text"
|
||||
: "—",
|
||||
);
|
||||
const formatValue = $derived(resultKind === "files" ? "ZIP (PNG)" : "PNG");
|
||||
const hasResult = $derived(
|
||||
resultKind === "image"
|
||||
? Boolean(result)
|
||||
: resultKind === "files"
|
||||
? Boolean(fileResult)
|
||||
: Boolean(textResult),
|
||||
);
|
||||
</script>
|
||||
|
||||
<div class="panel-head">
|
||||
@@ -68,7 +82,7 @@
|
||||
>
|
||||
<SchemaActions
|
||||
{inputMode}
|
||||
canDownload={resultKind === "image" && Boolean(result)}
|
||||
canDownload={hasResult}
|
||||
{running}
|
||||
{onupload}
|
||||
ongenerate={ongenerate ?? (() => {})}
|
||||
@@ -92,8 +106,8 @@
|
||||
<SchemaResultTile
|
||||
{resultKind}
|
||||
{result}
|
||||
{fileResult}
|
||||
{textResult}
|
||||
wide={resultKind !== "image"}
|
||||
{running}
|
||||
oncopy={oncopytext}
|
||||
{ondownloadtxt}
|
||||
@@ -105,7 +119,7 @@
|
||||
items={[
|
||||
{ caption: "SOURCE", value: sourceValue },
|
||||
{ caption: "RESULT", value: resultValue },
|
||||
{ caption: "FORMAT", value: "PNG" },
|
||||
{ caption: "FORMAT", value: formatValue },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
<script lang="ts">
|
||||
import { toDataUrl } from "$lib/core/io";
|
||||
import type { PixelImage } from "$lib/core/types";
|
||||
import type { FileResult } from "$lib/registry";
|
||||
import { Check, RefreshCw } from "@lucide/svelte";
|
||||
import SchemaTextResult from "./SchemaTextResult.svelte";
|
||||
|
||||
interface Props {
|
||||
resultKind?: "image" | "text" | "verdict";
|
||||
resultKind?: "image" | "text" | "verdict" | "files";
|
||||
result: PixelImage | null;
|
||||
fileResult?: FileResult | null;
|
||||
textResult?: string | null;
|
||||
wide?: boolean;
|
||||
running?: boolean;
|
||||
oncopy?: () => void;
|
||||
ondownloadtxt?: () => void;
|
||||
@@ -16,20 +17,31 @@
|
||||
let {
|
||||
resultKind = "image",
|
||||
result,
|
||||
fileResult = null,
|
||||
textResult = null,
|
||||
wide = false,
|
||||
running = false,
|
||||
oncopy,
|
||||
ondownloadtxt,
|
||||
}: Props = $props();
|
||||
|
||||
const resultUrl = $derived(result ? toDataUrl(result) : null);
|
||||
const partUrls = $derived(
|
||||
resultKind === "files" && fileResult
|
||||
? fileResult.files.map((file) => ({
|
||||
name: file.name,
|
||||
url: toDataUrl(file.image),
|
||||
}))
|
||||
: [],
|
||||
);
|
||||
</script>
|
||||
|
||||
<figure class="tile" style:grid-column={wide ? "1 / -1" : undefined}>
|
||||
<figure class="tile">
|
||||
<figcaption>
|
||||
<span>
|
||||
RESULT
|
||||
{#if resultKind === "files" && fileResult}
|
||||
<span class="count">{fileResult.files.length} parts</span>
|
||||
{/if}
|
||||
{#if running}
|
||||
<RefreshCw class="rotating" size="16" />
|
||||
{/if}
|
||||
@@ -54,6 +66,15 @@
|
||||
ondownload={ondownloadtxt ?? (() => {})}
|
||||
/>
|
||||
</div>
|
||||
{:else if resultKind === "files" && partUrls.length > 0}
|
||||
<div class="parts-grid">
|
||||
{#each partUrls as part (part.name)}
|
||||
<figure class="part">
|
||||
<img src={part.url} alt={part.name} loading="lazy" />
|
||||
<figcaption>{part.name}</figcaption>
|
||||
</figure>
|
||||
{/each}
|
||||
</div>
|
||||
{:else if resultKind === "image" && resultUrl}
|
||||
<img src={resultUrl} alt="result" />
|
||||
{:else if running}
|
||||
@@ -83,7 +104,7 @@
|
||||
min-height: clamp(var(--space-brand), 30vh, 60vh);
|
||||
border: var(--size-border) solid var(--color-border);
|
||||
border-radius: var(--radius-s);
|
||||
overflow: hidden;
|
||||
overflow: auto;
|
||||
background: var(--color-background-muted);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
@@ -103,6 +124,47 @@
|
||||
.empty {
|
||||
font: var(--font-size-s) var(--font-mono);
|
||||
}
|
||||
.count {
|
||||
margin-left: var(--space-m);
|
||||
color: var(--color-main);
|
||||
}
|
||||
.parts-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(
|
||||
auto-fill,
|
||||
minmax(var(--size-parts-grid-min), 1fr)
|
||||
);
|
||||
gap: var(--space-m);
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: var(--space-l);
|
||||
box-sizing: border-box;
|
||||
align-content: start;
|
||||
}
|
||||
.part {
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-s);
|
||||
}
|
||||
.part img {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
display: block;
|
||||
border: var(--size-border) solid var(--color-border);
|
||||
border-radius: var(--radius-s);
|
||||
background: repeating-conic-gradient(
|
||||
var(--color-checker-main) 0 25%,
|
||||
var(--color-checker-alt) 0 50%
|
||||
)
|
||||
50% / 16px 16px;
|
||||
}
|
||||
.part figcaption {
|
||||
font: var(--font-size-s) var(--font-mono);
|
||||
color: var(--color-text-muted);
|
||||
text-align: center;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.text-result-wrap {
|
||||
width: 100%;
|
||||
padding: var(--space-l);
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
|
||||
<figure class="tile">
|
||||
<figcaption><span>SOURCE</span></figcaption>
|
||||
<div class="canvas">
|
||||
<div class="canvas" class:checker={mode === "image"}>
|
||||
{#if mode === "text"}
|
||||
<div class="text-source-wrap">
|
||||
<SchemaTextSource
|
||||
@@ -72,6 +72,13 @@
|
||||
object-fit: contain;
|
||||
display: block;
|
||||
}
|
||||
.canvas.checker {
|
||||
background: repeating-conic-gradient(
|
||||
var(--color-checker-main) 0 25%,
|
||||
var(--color-checker-alt) 0 50%
|
||||
)
|
||||
50% / 28px 28px;
|
||||
}
|
||||
.empty {
|
||||
font: var(--font-size-s) var(--font-mono);
|
||||
}
|
||||
|
||||
@@ -7,7 +7,8 @@
|
||||
import type { PixelImage } from "$lib/core/types";
|
||||
import { execute } from "$lib/executor";
|
||||
import { t } from "$lib/i18n/t";
|
||||
import type { ToolEntry } from "$lib/registry";
|
||||
import type { FileResult, ToolEntry } from "$lib/registry";
|
||||
import { downloadZip } from "$lib/zip";
|
||||
import {
|
||||
defaultSchemaParams,
|
||||
sanitizeSchemaParams,
|
||||
@@ -29,6 +30,7 @@
|
||||
let values = $state<Record<string, unknown>>({});
|
||||
let source = $state<PixelImage | null>(null);
|
||||
let result = $state<PixelImage | null>(null);
|
||||
let fileResult = $state<FileResult | null>(null);
|
||||
let textSource = $state("");
|
||||
let textResult = $state<string | null>(null);
|
||||
let running = $state(false);
|
||||
@@ -50,6 +52,7 @@
|
||||
function reset() {
|
||||
values = schema ? defaultSchemaParams(schema) : {};
|
||||
result = null;
|
||||
fileResult = null;
|
||||
textResult = null;
|
||||
error = "";
|
||||
}
|
||||
@@ -76,9 +79,15 @@
|
||||
if (resultKind === "image") {
|
||||
result = out as PixelImage;
|
||||
textResult = null;
|
||||
fileResult = null;
|
||||
} else if (resultKind === "files") {
|
||||
fileResult = out as FileResult;
|
||||
result = null;
|
||||
textResult = null;
|
||||
} else {
|
||||
textResult = out as string;
|
||||
result = null;
|
||||
fileResult = null;
|
||||
}
|
||||
} catch (e) {
|
||||
error = errorText(e);
|
||||
@@ -88,7 +97,13 @@
|
||||
}
|
||||
|
||||
async function download() {
|
||||
if (!result || !schema) return;
|
||||
if (!schema) return;
|
||||
if (resultKind === "files") {
|
||||
if (!fileResult) return;
|
||||
await downloadZip(fileResult.files, `${tool.id}.zip`);
|
||||
return;
|
||||
}
|
||||
if (!result) return;
|
||||
const out = tool.output;
|
||||
const quality = out?.qualityParamId
|
||||
? Number(values[out.qualityParamId]) / 100
|
||||
@@ -164,6 +179,7 @@
|
||||
<SchemaPreview
|
||||
{source}
|
||||
{result}
|
||||
{fileResult}
|
||||
{textSource}
|
||||
{textResult}
|
||||
{inputMode}
|
||||
|
||||
@@ -6,9 +6,11 @@ import {
|
||||
flip,
|
||||
resize,
|
||||
rotate90,
|
||||
splitToParts,
|
||||
tile,
|
||||
} from "./geometry";
|
||||
import { makeImage } from "./test-helpers";
|
||||
import { expectImageEqual, makeImage } from "./test-helpers";
|
||||
import type { PixelImage } from "./types";
|
||||
|
||||
const square = () =>
|
||||
makeImage(2, 2, [
|
||||
@@ -210,3 +212,113 @@ describe("resize", () => {
|
||||
expect(() => resize(img, 10.5, 10)).toThrow(/errors\.sizeInt/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("splitToParts", () => {
|
||||
function grid(w: number, h: number): PixelImage {
|
||||
const pixels: number[][] = [];
|
||||
for (let y = 0; y < h; y++) {
|
||||
for (let x = 0; x < w; x++) {
|
||||
const v = y * w + x + 1;
|
||||
pixels.push([v, v, v, 255]);
|
||||
}
|
||||
}
|
||||
return makeImage(w, h, pixels);
|
||||
}
|
||||
|
||||
it("ровное деление 4x4 на 2x2 даёт четыре части 2x2 в row-major порядке", () => {
|
||||
const parts = splitToParts(grid(4, 4), 2, 2);
|
||||
expect(parts).toHaveLength(4);
|
||||
for (const part of parts) {
|
||||
expect(part.width).toBe(2);
|
||||
expect(part.height).toBe(2);
|
||||
}
|
||||
expectImageEqual(parts[0], [
|
||||
[1, 1, 1, 255],
|
||||
[2, 2, 2, 255],
|
||||
[5, 5, 5, 255],
|
||||
[6, 6, 6, 255],
|
||||
]);
|
||||
expectImageEqual(parts[1], [
|
||||
[3, 3, 3, 255],
|
||||
[4, 4, 4, 255],
|
||||
[7, 7, 7, 255],
|
||||
[8, 8, 8, 255],
|
||||
]);
|
||||
expectImageEqual(parts[2], [
|
||||
[9, 9, 9, 255],
|
||||
[10, 10, 10, 255],
|
||||
[13, 13, 13, 255],
|
||||
[14, 14, 14, 255],
|
||||
]);
|
||||
expectImageEqual(parts[3], [
|
||||
[11, 11, 11, 255],
|
||||
[12, 12, 12, 255],
|
||||
[15, 15, 15, 255],
|
||||
[16, 16, 16, 255],
|
||||
]);
|
||||
});
|
||||
|
||||
it("неделимый размер: канвас дополняется прозрачным, части одинаковые", () => {
|
||||
const parts = splitToParts(grid(5, 3), 2, 2);
|
||||
expect(parts).toHaveLength(4);
|
||||
for (const part of parts) {
|
||||
expect(part.width).toBe(3);
|
||||
expect(part.height).toBe(2);
|
||||
}
|
||||
expectImageEqual(parts[0], [
|
||||
[1, 1, 1, 255],
|
||||
[2, 2, 2, 255],
|
||||
[3, 3, 3, 255],
|
||||
[6, 6, 6, 255],
|
||||
[7, 7, 7, 255],
|
||||
[8, 8, 8, 255],
|
||||
]);
|
||||
expectImageEqual(parts[1], [
|
||||
[4, 4, 4, 255],
|
||||
[5, 5, 5, 255],
|
||||
[0, 0, 0, 0],
|
||||
[9, 9, 9, 255],
|
||||
[10, 10, 10, 255],
|
||||
[0, 0, 0, 0],
|
||||
]);
|
||||
expectImageEqual(parts[2], [
|
||||
[11, 11, 11, 255],
|
||||
[12, 12, 12, 255],
|
||||
[13, 13, 13, 255],
|
||||
[0, 0, 0, 0],
|
||||
[0, 0, 0, 0],
|
||||
[0, 0, 0, 0],
|
||||
]);
|
||||
expectImageEqual(parts[3], [
|
||||
[14, 14, 14, 255],
|
||||
[15, 15, 15, 255],
|
||||
[0, 0, 0, 0],
|
||||
[0, 0, 0, 0],
|
||||
[0, 0, 0, 0],
|
||||
[0, 0, 0, 0],
|
||||
]);
|
||||
});
|
||||
|
||||
it("одна колонка или строка — полосы без паддинга", () => {
|
||||
const parts = splitToParts(grid(3, 4), 1, 2);
|
||||
expect(parts).toHaveLength(2);
|
||||
expect(parts[0].width).toBe(3);
|
||||
expect(parts[0].height).toBe(2);
|
||||
expect(parts[1].height).toBe(2);
|
||||
});
|
||||
|
||||
it("части в row-major порядке: последний кусок содержит правый нижний пиксель", () => {
|
||||
const img = grid(5, 3);
|
||||
const parts = splitToParts(img, 2, 2);
|
||||
expect(parts[parts.length - 1].data[0]).toBe(14);
|
||||
expect(parts[parts.length - 1].data[4]).toBe(15);
|
||||
expect(parts[parts.length - 1].data[8]).toBe(0);
|
||||
});
|
||||
|
||||
it("дробные и нулевые значения колонок/строк нормализуются", () => {
|
||||
const parts = splitToParts(grid(6, 6), 2.9, 0);
|
||||
expect(parts).toHaveLength(2);
|
||||
expect(parts[0].width).toBe(3);
|
||||
expect(parts[0].height).toBe(6);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -56,6 +56,36 @@ export function tile(
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Разрезает изображение на сетку columns × rows строго равных частей.
|
||||
* Холст дополняется прозрачным до кратного размера (`ceil(width / cols)`),
|
||||
* поэтому весь исходный контент сохраняется. Порядок — row-major: сначала все
|
||||
* столбцы первой строки (part 1-1, 1-2, …), затем второй и т.д.
|
||||
*/
|
||||
export function splitToParts(
|
||||
img: PixelImage,
|
||||
columns: number,
|
||||
rows: number,
|
||||
): PixelImage[] {
|
||||
const cols = Math.max(1, Math.trunc(columns));
|
||||
const rowsCount = Math.max(1, Math.trunc(rows));
|
||||
const pieceW = Math.ceil(img.width / cols);
|
||||
const pieceH = Math.ceil(img.height / rowsCount);
|
||||
const gridW = pieceW * cols;
|
||||
const gridH = pieceH * rowsCount;
|
||||
const padded =
|
||||
gridW === img.width && gridH === img.height
|
||||
? img
|
||||
: expandCanvas(img, 0, 0, gridW - img.width, gridH - img.height);
|
||||
const parts: PixelImage[] = [];
|
||||
for (let row = 0; row < rowsCount; row++) {
|
||||
for (let col = 0; col < cols; col++) {
|
||||
parts.push(crop(padded, col * pieceW, row * pieceH, pieceW, pieceH));
|
||||
}
|
||||
}
|
||||
return parts;
|
||||
}
|
||||
|
||||
export function centerByAlpha(img: PixelImage): PixelImage {
|
||||
let minX = img.width;
|
||||
let minY = img.height;
|
||||
|
||||
@@ -88,6 +88,12 @@ function ensureWorker(): Worker | null {
|
||||
width?: number;
|
||||
height?: number;
|
||||
data?: Uint8ClampedArray;
|
||||
files?: {
|
||||
name: string;
|
||||
width: number;
|
||||
height: number;
|
||||
data: Uint8ClampedArray;
|
||||
}[];
|
||||
text?: string;
|
||||
error?: string;
|
||||
errorKey?: string;
|
||||
@@ -102,6 +108,17 @@ function ensureWorker(): Worker | null {
|
||||
height: payload.height,
|
||||
data: new Uint8ClampedArray(payload.data),
|
||||
});
|
||||
} else if (payload.ok && Array.isArray(payload.files)) {
|
||||
entry.resolve({
|
||||
files: payload.files.map((file) => ({
|
||||
name: file.name,
|
||||
image: {
|
||||
width: file.width,
|
||||
height: file.height,
|
||||
data: new Uint8ClampedArray(file.data),
|
||||
},
|
||||
})),
|
||||
});
|
||||
} else if (payload.ok && typeof payload.text === "string") {
|
||||
entry.resolve(payload.text);
|
||||
} else if (payload.errorKey) {
|
||||
|
||||
@@ -20,6 +20,7 @@ type WorkerResponse =
|
||||
height?: number;
|
||||
data?: Uint8ClampedArray;
|
||||
text?: string;
|
||||
files?: WorkerImageFile[];
|
||||
}
|
||||
| {
|
||||
id: number;
|
||||
@@ -29,6 +30,13 @@ type WorkerResponse =
|
||||
errorVars?: Record<string, string | number>;
|
||||
};
|
||||
|
||||
type WorkerImageFile = {
|
||||
name: string;
|
||||
width: number;
|
||||
height: number;
|
||||
data: Uint8ClampedArray;
|
||||
};
|
||||
|
||||
self.onmessage = (event: MessageEvent<WorkerRequest>) => {
|
||||
void handle(event.data);
|
||||
};
|
||||
@@ -59,6 +67,19 @@ async function handle(request: WorkerRequest): Promise<void> {
|
||||
} satisfies WorkerResponse);
|
||||
return;
|
||||
}
|
||||
if ("files" in output) {
|
||||
const files = output.files.map((file) => ({
|
||||
name: file.name,
|
||||
width: file.image.width,
|
||||
height: file.image.height,
|
||||
data: file.image.data,
|
||||
}));
|
||||
(self as unknown as Worker).postMessage(
|
||||
{ id: request.id, ok: true, files } satisfies WorkerResponse,
|
||||
files.map((file) => file.data.buffer),
|
||||
);
|
||||
return;
|
||||
}
|
||||
(self as unknown as Worker).postMessage(
|
||||
{
|
||||
id: request.id,
|
||||
|
||||
@@ -150,6 +150,7 @@ export const en: Dict = {
|
||||
resizeSize: "Width and/or height must be positive",
|
||||
cropSize: "Crop width and height must be positive",
|
||||
sizePositive: "Dimensions must be positive and finite",
|
||||
tooManyParts: "Too many parts ({count}). Maximum is 1000.",
|
||||
},
|
||||
tools: {
|
||||
"png-file-size": {
|
||||
|
||||
@@ -151,6 +151,7 @@ export const ru: Dict = {
|
||||
resizeSize: "Ширина и/или высота должны быть положительными",
|
||||
cropSize: "Ширина и высота области обрезки должны быть положительными",
|
||||
sizePositive: "Размеры должны быть положительными и конечными",
|
||||
tooManyParts: "Слишком много частей ({count}). Ограничение — 1000.",
|
||||
},
|
||||
tools: {
|
||||
"jpg-to-png": {
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
padToRatio,
|
||||
resize,
|
||||
rotate90,
|
||||
splitToParts,
|
||||
symmetricCopy,
|
||||
tile,
|
||||
trimToContent,
|
||||
@@ -23,7 +24,12 @@ import {
|
||||
type FlipAxis,
|
||||
} from "../core/geometry";
|
||||
import { field, toolSchema, type Dimension } from "../registry-schema";
|
||||
import { imgTool, type ToolEntry } from "./types";
|
||||
import {
|
||||
imgTool,
|
||||
requireSource,
|
||||
type ToolEntry,
|
||||
type ToolImageFile,
|
||||
} from "./types";
|
||||
|
||||
interface AddBorderParams {
|
||||
thickness: number;
|
||||
@@ -356,6 +362,51 @@ const tileTool: ToolEntry<TileParams> = {
|
||||
run: imgTool((img, p) => tile(img, p.columns, p.rows)),
|
||||
};
|
||||
|
||||
const MAX_SPLIT_PARTS = 1000;
|
||||
|
||||
interface SplitPartsParams {
|
||||
columns: number;
|
||||
rows: number;
|
||||
}
|
||||
|
||||
export const splitPartsSchema = toolSchema<SplitPartsParams>({
|
||||
columns: field.number({ min: 1, max: 6, step: 1, default: 2 }),
|
||||
rows: field.number({ min: 1, max: 6, step: 1, default: 2 }),
|
||||
});
|
||||
|
||||
const splitPartsTool: ToolEntry<SplitPartsParams> = {
|
||||
id: "split-into-parts-png",
|
||||
title: "Split PNG into parts",
|
||||
description:
|
||||
"Divides the image into a grid of equal-sized parts. The canvas is padded with transparency to keep every part the same size.",
|
||||
category: "geometry",
|
||||
schema: splitPartsSchema,
|
||||
input: "image",
|
||||
result: "files",
|
||||
run: (ctx) => {
|
||||
const img = requireSource(ctx);
|
||||
const params = ctx.params as SplitPartsParams;
|
||||
const cols = Math.max(1, Math.trunc(params.columns));
|
||||
const rowsCount = Math.max(1, Math.trunc(params.rows));
|
||||
const count = cols * rowsCount;
|
||||
if (count > MAX_SPLIT_PARTS) {
|
||||
throw new ToolError("errors.tooManyParts", { count });
|
||||
}
|
||||
const parts = splitToParts(img, cols, rowsCount);
|
||||
const rowLen = String(rowsCount).length;
|
||||
const colLen = String(cols).length;
|
||||
const files: ToolImageFile[] = parts.map((image, index) => {
|
||||
const col = (index % cols) + 1;
|
||||
const row = Math.floor(index / cols) + 1;
|
||||
return {
|
||||
name: `part-${String(row).padStart(rowLen, "0")}-${String(col).padStart(colLen, "0")}.png`,
|
||||
image,
|
||||
};
|
||||
});
|
||||
return { files };
|
||||
},
|
||||
};
|
||||
|
||||
interface EmptyParams {}
|
||||
|
||||
export const centerByAlphaSchema = toolSchema<EmptyParams>({});
|
||||
@@ -605,4 +656,5 @@ export const geometryEntries = [
|
||||
swapOrientationTool,
|
||||
symmetricCopyTool,
|
||||
shiftTool,
|
||||
splitPartsTool,
|
||||
];
|
||||
|
||||
@@ -23,6 +23,8 @@ export type {
|
||||
ToolContext,
|
||||
ToolEntry,
|
||||
ToolResult,
|
||||
ToolImageFile,
|
||||
FileResult,
|
||||
} from "./types";
|
||||
|
||||
export const TOOLS: ToolEntry[] = [
|
||||
|
||||
@@ -3,10 +3,10 @@ import { TOOLS } from ".";
|
||||
import { PREVIEW_GROUPS } from "../catalog";
|
||||
import type { PixelImage } from "../core/types";
|
||||
import { defaultSchemaParams, sanitizeSchemaParams } from "../registry-schema";
|
||||
import type { ToolResult } from "./types";
|
||||
import type { FileResult, ToolResult } from "./types";
|
||||
|
||||
function asImage(result: ToolResult): PixelImage {
|
||||
if (typeof result === "string") {
|
||||
if (typeof result === "string" || "files" in result) {
|
||||
throw new Error("expected an image result");
|
||||
}
|
||||
return result;
|
||||
@@ -517,6 +517,90 @@ describe("registry-new (переведённые инструменты)", () =>
|
||||
});
|
||||
});
|
||||
|
||||
describe("split-into-parts-png", () => {
|
||||
it("объявлен с result=files и дефолтом 2x2", () => {
|
||||
const tool = TOOLS.find((t) => t.id === "split-into-parts-png")!;
|
||||
expect(tool).toBeDefined();
|
||||
expect(tool.result).toBe("files");
|
||||
const defaults = sanitizeSchemaParams(tool.schema, {});
|
||||
expect(defaults.columns).toBe(2);
|
||||
expect(defaults.rows).toBe(2);
|
||||
});
|
||||
|
||||
it("на ровном размере даёт cols*rows частей одинакового размера", async () => {
|
||||
const tool = TOOLS.find((t) => t.id === "split-into-parts-png")!;
|
||||
const files = asFiles(
|
||||
await tool.run({
|
||||
source: solid(100, 100),
|
||||
params: sanitizeSchemaParams(tool.schema, {
|
||||
columns: 2,
|
||||
rows: 2,
|
||||
}),
|
||||
}),
|
||||
).files;
|
||||
expect(files).toHaveLength(4);
|
||||
for (const file of files) {
|
||||
expect(file.image.width).toBe(50);
|
||||
expect(file.image.height).toBe(50);
|
||||
}
|
||||
expect(files.map((f) => f.name)).toEqual([
|
||||
"part-1-1.png",
|
||||
"part-1-2.png",
|
||||
"part-2-1.png",
|
||||
"part-2-2.png",
|
||||
]);
|
||||
});
|
||||
|
||||
it("на неделимом размере части строго равные (канвас дополняется)", async () => {
|
||||
const tool = TOOLS.find((t) => t.id === "split-into-parts-png")!;
|
||||
const files = asFiles(
|
||||
await tool.run({
|
||||
source: solid(101, 77),
|
||||
params: sanitizeSchemaParams(tool.schema, {
|
||||
columns: 3,
|
||||
rows: 2,
|
||||
}),
|
||||
}),
|
||||
).files;
|
||||
expect(files).toHaveLength(6);
|
||||
for (const file of files) {
|
||||
expect(file.image.width).toBe(34);
|
||||
expect(file.image.height).toBe(39);
|
||||
}
|
||||
});
|
||||
|
||||
it("работает максимум 6x6 = 36 частей", async () => {
|
||||
const tool = TOOLS.find((t) => t.id === "split-into-parts-png")!;
|
||||
const files = asFiles(
|
||||
await tool.run({
|
||||
source: solid(120, 120),
|
||||
params: sanitizeSchemaParams(tool.schema, {
|
||||
columns: 6,
|
||||
rows: 6,
|
||||
}),
|
||||
}),
|
||||
).files;
|
||||
expect(files).toHaveLength(36);
|
||||
});
|
||||
|
||||
it("выбрасывает errors.tooManyParts при переполнении", () => {
|
||||
const tool = TOOLS.find((t) => t.id === "split-into-parts-png")!;
|
||||
expect(() =>
|
||||
tool.run({
|
||||
source: solid(120, 120),
|
||||
params: { columns: 1000, rows: 1000 },
|
||||
}),
|
||||
).toThrow("errors.tooManyParts");
|
||||
});
|
||||
});
|
||||
|
||||
function asFiles(result: ToolResult): FileResult {
|
||||
if (typeof result === "string" || !("files" in result)) {
|
||||
throw new Error("expected a files result");
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function solid(width: number, height: number) {
|
||||
const data = new Uint8ClampedArray(width * height * 4).fill(255);
|
||||
return { width, height, data };
|
||||
|
||||
@@ -19,11 +19,15 @@ export const INPUT_MODES = {
|
||||
} as const;
|
||||
export type InputMode = (typeof INPUT_MODES)[keyof typeof INPUT_MODES];
|
||||
|
||||
/** Тип результата: картинка (по умолчанию), большой текст или короткий вердикт. */
|
||||
/**
|
||||
* Тип результата: картинка (по умолчанию), большой текст, короткий вердикт
|
||||
* или набор файлов (1 → many, скачивается zip-архивом).
|
||||
*/
|
||||
export const RESULT_KINDS = {
|
||||
image: "image",
|
||||
text: "text",
|
||||
verdict: "verdict",
|
||||
files: "files",
|
||||
} as const;
|
||||
export type ResultKind = (typeof RESULT_KINDS)[keyof typeof RESULT_KINDS];
|
||||
|
||||
@@ -34,7 +38,18 @@ export interface ToolContext<P> {
|
||||
text?: string;
|
||||
}
|
||||
|
||||
export type ToolResult = PixelImage | string;
|
||||
/** Один файл мультифайлового результата (1 → many). */
|
||||
export interface ToolImageFile {
|
||||
name: string;
|
||||
image: PixelImage;
|
||||
}
|
||||
|
||||
/** Результат-набор файлов; скачивается zip-архивом. */
|
||||
export interface FileResult {
|
||||
files: ToolImageFile[];
|
||||
}
|
||||
|
||||
export type ToolResult = PixelImage | string | FileResult;
|
||||
|
||||
/**
|
||||
* Инструмент нового registry: полностью типизирован на `Params`, схема —
|
||||
|
||||
@@ -91,6 +91,7 @@ export const TOOL_ICONS: Record<string, typeof AppWindow> = {
|
||||
"add-border-png": Frame,
|
||||
"fit-on-background-png": ImageIcon,
|
||||
"tile-png": Grid3x3,
|
||||
"split-into-parts-png": Grid3x3,
|
||||
"center-by-alpha-png": Crosshair,
|
||||
"create-empty-png": FilePlus2,
|
||||
"single-color-png": PaintBucket,
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { zipSync } from "fflate";
|
||||
import { downloadBlob, encode } from "./core/io";
|
||||
import type { ToolImageFile } from "./registry";
|
||||
|
||||
/**
|
||||
* Собирает набор картинок (1 → many) в zip-архив и скачивает его.
|
||||
* Картинки кодируются как PNG; кодирование требует DOM — вызов из UI-слоя.
|
||||
*/
|
||||
export async function downloadZip(
|
||||
files: ToolImageFile[],
|
||||
zipName: string,
|
||||
): Promise<void> {
|
||||
if (files.length === 0) return;
|
||||
const entries: Record<string, Uint8Array> = {};
|
||||
for (const file of files) {
|
||||
const blob = await encode(file.image, "image/png");
|
||||
entries[file.name] = new Uint8Array(await blob.arrayBuffer());
|
||||
}
|
||||
const zipped = zipSync(entries);
|
||||
downloadBlob(new Blob([zipped], { type: "application/zip" }), zipName);
|
||||
}
|
||||