Compare commits

...
17 Commits
60 changed files with 1586 additions and 138 deletions
+64
View File
@@ -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
+20 -46
View File
@@ -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 строк), атомарными.
+81
View File
@@ -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.
+29 -7
View File
@@ -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-фаз 57)
@@ -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).
+28
View File
@@ -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 — не
+3 -3
View File
@@ -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`, описания категорий).
+30 -23
View File
@@ -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).
+5 -4
View File
@@ -2,10 +2,11 @@
> **Статус:** Фаза 1 (полноценный TS-сайт) в основном выполнена — сайт живёт,
> каталог переведён в типизированный `registry` (121/125), новый дизайн по
> `plan-redesign.md` стал основным на корневых маршрутах (C17 выполнен), старый
> UI — на `/v1/*` (удаление отложено до C19). Tech debt закрыт. Следующий этап:
> C18C21 (`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. Решения
+4 -3
View File
@@ -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,
+2 -1
View File
@@ -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",
Binary file not shown.

After

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 162 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 225 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 203 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 290 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 601 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 B

+1
View File
@@ -0,0 +1 @@
this is definitely not a png file
+329
View 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);
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 879 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 722 B

Binary file not shown.

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: {
+16
View File
@@ -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" }],
},
],
},
);
+28
View File
@@ -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(),
},
});
},
};
},
};
+15
View File
@@ -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;
}
+28
View File
@@ -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) => ({
+2 -1
View File
@@ -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"
}
}
+8
View File
@@ -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
+1
View File
@@ -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;
+18 -4
View File
@@ -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>
+67 -5
View File
@@ -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);
}
+18 -2
View File
@@ -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}
+113 -1
View File
@@ -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);
});
});
+30
View File
@@ -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;
+17
View File
@@ -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) {
+21
View File
@@ -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,
+1
View File
@@ -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": {
+1
View File
@@ -151,6 +151,7 @@ export const ru: Dict = {
resizeSize: "Ширина и/или высота должны быть положительными",
cropSize: "Ширина и высота области обрезки должны быть положительными",
sizePositive: "Размеры должны быть положительными и конечными",
tooManyParts: "Слишком много частей ({count}). Ограничение — 1000.",
},
tools: {
"jpg-to-png": {
+53 -1
View File
@@ -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,
];
+2
View File
@@ -23,6 +23,8 @@ export type {
ToolContext,
ToolEntry,
ToolResult,
ToolImageFile,
FileResult,
} from "./types";
export const TOOLS: ToolEntry[] = [
+86 -2
View File
@@ -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 };
+17 -2
View File
@@ -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`, схема
+1
View File
@@ -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,
+21
View File
@@ -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);
}