Compare commits

...
8 Commits
141 changed files with 3081 additions and 1292 deletions
+10
View File
@@ -2,6 +2,16 @@
All notable changes to this project will be documented in this file. See [conventional commits](https://www.conventionalcommits.org/) for commit guidelines. All notable changes to this project will be documented in this file. See [conventional commits](https://www.conventionalcommits.org/) for commit guidelines.
- - - - - -
## [v0.5.0](https://github.com/Ku6epXBOCTuK/easy-png-tools/compare/76039f7bf3247ef9711113608f5ec2d01a7fa56f..v0.5.0) - 2026-10-09
#### Features
- add show mask chip for tools - ([c22c6e5](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/c22c6e55741f99a6c8a79a82b7b3deadd33fa3dc)) - Ku6epXBOCTuK
- add chains page, update pipeline usage - ([a51e224](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/a51e224d02ccd45ca0adc69135112b8a0a3839c7)) - Ku6epXBOCTuK
- add limit size to download flow - ([8dcc263](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/8dcc263461111937d1b116cbfec41329c314b7b9)) - Ku6epXBOCTuK
#### Bug Fixes
- image decode error i18n - ([76039f7](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/76039f7bf3247ef9711113608f5ec2d01a7fa56f)) - Ku6epXBOCTuK
- - -
## [v0.4.0](https://github.com/Ku6epXBOCTuK/easy-png-tools/compare/83e75b5589ee200b6e216bc0187404b8be086149..v0.4.0) - 2026-10-07 ## [v0.4.0](https://github.com/Ku6epXBOCTuK/easy-png-tools/compare/83e75b5589ee200b6e216bc0187404b8be086149..v0.4.0) - 2026-10-07
#### Features #### Features
- add og description\image\etc - ([31ace35](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/31ace359d7ff187cbe9ba968facc498e75e66b3d)) - Ku6epXBOCTuK - add og description\image\etc - ([31ace35](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/31ace359d7ff187cbe9ba968facc498e75e66b3d)) - Ku6epXBOCTuK
+12 -8
View File
@@ -48,14 +48,17 @@ Coverage). Всё остальное в `core/**` — чистые функци
Страница исполняет ровно один шаг: загрузил файл → применила инструмент → Страница исполняет ровно один шаг: загрузил файл → применила инструмент →
скачала результат. Отсутствуют: скачала результат. Отсутствуют:
- **Пайплайн / цепочки шагов** — модуля нет, состояния нет, localStorage для - **Пайплайн / цепочки шагов** — реализованы: модуль шагов
него нет. Модель страницы уже хранит список `steps`, но исполняет первый шаг, `web/src/lib/pipeline.svelte.ts` (createChain/parseChain/serializeSteps),
а `registry.test.ts` требует, чтобы шаг был ровно один: композиция формы из именованные цепочки — `web/src/lib/chains.svelte.ts` (localStorage
полей двух инструментов — отдельная работа, описанная как фаза 11 `chains:named`). Страница инструмента всегда открывается чистой; при первом
`docs/roadmap.md`. Под неё зарезервированы структурном изменении (добавлен шаг, заменён первый инструмент) цепочка
`components/display/StepCard.svelte` (сейчас импортируется только витриной автоматически становится именованной (имя — дата/время) и маршрут бесшовно
`/kit`) и ключи `chain.*`, `errors.badPipelineShape`, меняется на `/pipeline?id=<id>` (передача исходника через handoff-стор,
`errors.pipelineVersion`, `errors.noSteps`. replaceState). `/chains` — список цепочек (переименовать/удалить/открыть/
создать). Легаси-автосейв `pipeline:<slug>` снят без миграции (ключи просто
перестают читаться). Экспортный JSON-формат не устоялся и может меняться до
фазы 11 `docs/roadmap.md`.
- **Разделения страниц, которые делят один инструмент** — сводит все - **Разделения страниц, которые делят один инструмент** — сводит все
convert-страницы на один инструмент; задача в `docs/backlog.md`. Пока convert-страницы на один инструмент; задача в `docs/backlog.md`. Пока
`registry.test.ts` не требует, чтобы на инструмент ссылались две страницы, а `registry.test.ts` не требует, чтобы на инструмент ссылались две страницы, а
@@ -119,6 +122,7 @@ registry/{tools,pages}/*.ts
| `result`? | `"image" \| "text" \| "verdict" \| "files"` — что отдаёт | | `result`? | `"image" \| "text" \| "verdict" \| "files"` — что отдаёт |
| `domOnly`? | инструмент требует DOM (canvas); превью-executor запускает его напрямую | | `domOnly`? | инструмент требует DOM (canvas); превью-executor запускает его напрямую |
| `output`? | mime/ext/quality для скачивания (не у всех) | | `output`? | mime/ext/quality для скачивания (не у всех) |
| `runMask`? | ч/б превью «что изменится»; тогл на тайле результата, в цепочку не идёт |
| `run(ctx)` | единственный метод: `ctx` → `ToolResult` | | `run(ctx)` | единственный метод: `ctx` → `ToolResult` |
`Page` (записи в `registry/pages/<group>.ts`): `Page` (записи в `registry/pages/<group>.ts`):
+46 -43
View File
@@ -18,21 +18,6 @@
- [ ] посмотреть реализацию flip animation svelte для анимации переключения - [ ] посмотреть реализацию flip animation svelte для анимации переключения
режима aligned\normal режима aligned\normal
- [ ] переделать визуал toggle компонента (которым режим переключается) - [ ] переделать визуал toggle компонента (которым режим переключается)
- [ ] **Идея 1. Заменить инструмент шага без удаления** — оценка M. В заголовке
карточки шага кнопка «поменять инструмент»: заменяет инструмент на месте
(любой шаг, не только первый), остальные шаги и их параметры сохраняются,
вход перечитывается. Открытый вопрос: что делать с несовместимыми входами
(text-source ↔ file).
- [ ] **Идея 3. Несколько цепочек: сохранение и загрузка** — оценка M.
Именованные цепочки в localStorage (список, создать/переименовать/
удалить), быстрое переключение. Формат экспортного JSON расширить
опциональным полем имени; обратной совместимости со старыми выгрузками не
делаем — формат не устоялся: он появится вместе с фазой 11
`docs/roadmap.md` и до её старта может измениться. Плюс отдельная страница
со списком всех цепочек: удалить, переименовать, добавить новую,
«использовать» (переход на страницу инструмента с этой цепочкой). На
странице — упрощённый вид: только список инструментов цепочки, без
реального выполнения и параметров.
- [ ] **Идея 4. Batch-обработка архивов выбранным chain** — оценка L. Вход — - [ ] **Идея 4. Batch-обработка архивов выбранным chain** — оценка L. Вход —
zip-архив картинок (распаковка в браузере), прогон текущего или выбранного zip-архив картинок (распаковка в браузере), прогон текущего или выбранного
сохранённого chain по каждому файлу, сборка результата обратно в zip для сохранённого chain по каждому файлу, сборка результата обратно в zip для
@@ -86,15 +71,42 @@
- [ ] **Идея 6. Конвертация формата — НЕ отдельный инструмент, а выбор в кнопке - [ ] **Идея 6. Конвертация формата — НЕ отдельный инструмент, а выбор в кнопке
Download** — оценка M/L. Решение и продуктовое обоснование: Download** — оценка M/L. Решение и продуктовое обоснование:
`docs/plan-seo.md` §0 и задача **S1f**; почему селектор не заменяет `docs/plan-seo.md` §0 и задача **S1f**; почему селектор не заменяет
существующие convert-страницы — `docs/plan-seo.md` §5. Механическая часть существующие convert-страницы — `docs/plan-seo.md` §5. Селектор формата и
уже есть: `Tool.output` (`OutputFormat`: mime/ext/qualityParamId) и качества в Download уже есть; «сжатие до целевого КБ» реализовано
кодирование по нему в `SchemaToolView.download()` — (`fitWithinBytes` в `web/src/lib/core/io.ts`: бинарный поиск по quality
`docs/architecture.md`, раздел 3. Что осталось сверх S1f: - экспорт с для lossy, по k квантования для PNG; для BMP — предупреждение, что лимит
лимитом размера → «сжатие до целевого КБ»: для lossy это quality в не применим). Что осталось: нужна ли отдельная страница под интент «сжать
селекторе формата, для PNG — бинарный поиск по k из квантования. Нужна ли до N КБ» — открытый вопрос с вордстатом, имя не выбрано
отдельная страница под этот интент — открытый вопрос с вордстатом, имя не (`docs/plan-seo.md` §5). Проработать вместе с достройкой download-флоу.
выбрано (`docs/plan-seo.md` §5). Проработать вместе с достройкой - [ ] **S1g под вопросом** — исходная формулировка (`docs/plan-seo.md` §4): на
download-флоу. convert-страницах блокировать в меню Download формат исходника, чтобы
`convert-png-to-jpg` не сохранял в png. Против: пользователь может
захотеть сохранить обработанный PNG или JPEG с другим качеством — блок
мешает легальному сценарию. Решение не принято; перед реализацией
переобдумать.
- [ ] **Лимит КБ: сигнал о недостижимости** — если даже минимальное качество /
k=2 не влезает в лимит, файл скачивается молча с превышением. Нужно:
`fitWithinBytes` (`web/src/lib/core/io.ts`) возвращает также достигнутый
размер, UI показывает warning «не удалось ужаться до N КБ, получилось M».
- [ ] **Лимит КБ: индикация активного сжатия** — сейчас заданный лимит никак не
виден, пока меню Download закрыто: пользователь может забыть про него и
случайно сжать следующие файлы. Нужен постоянный индикатор (значок/
подпись на кнопке или рядом). Связанный вопрос персистентности: лимит —
`$state` в `SchemaToolView.svelte`, при переходе на другой инструмент
сбрасывается в `init()` и в localStorage не пишется (в отличие от шагов
цепочки). Это безопасное поведение по умолчанию, но стоит осознанно
решить: оставить сброс при смене страницы или сохранять лимит (per-tool?
глобально?) вместе с цепочкой.
- [ ] **Лимит КБ/качество: превью итогового результата** — обдумать UI/UX: при
сжатии по размеру или низкому качеству дать возможность посмотреть
итоговый файл до скачивания — при сильном сжатии (низкий quality, грубое
квантование в PNG) искажения могут быть настолько заметны, что
пользователь откажется от варианта и выберет другой формат/лимит. Сейчас
превью показывает результат инструмента до re-encode, а сжатая версия
существует только внутри `fitWithinBytes`/`encode` в момент скачивания.
Открытые вопросы: превью на лету (дорого — encode на каждый ввод) или по
кнопке «предпросмотр»; где показывать (оверлей/замена тайла результата);
связь с идеей 14 (отображение маски) и пунктом про зум превью выше.
- [ ] **Идея 7. Страница `watermark-image-png` (водяной знак картинкой)** — - [ ] **Идея 7. Страница `watermark-image-png` (водяной знак картинкой)** —
оценка M. Нужна overlay-механика: второй источник изображения оценка M. Нужна overlay-механика: второй источник изображения
(getOverlay/store), параметры scale/opacity/position/margin/aspect. (getOverlay/store), параметры scale/opacity/position/margin/aspect.
@@ -119,24 +131,21 @@
отдельное решение о формате. отдельное решение о формате.
- [ ] **Идея 12. HARD-серия** (glitch, barcode, signature-extract, - [ ] **Идея 12. HARD-серия** (glitch, barcode, signature-extract,
handwritten-digital). handwritten-digital).
- [ ] **Идея 14. Отображение маски для инструментов (`runMask`)** — придумать и
реализовать процесс показа маски результата. **Контекст:** удалённый UI-v1
умел показывать ч/б превью-маску «что будет вырезано» отдельным полем; при
переходе на единый `run(ctx)` этого поля не осталось — executor и
schema-превью зовут `run()` напрямую, маска не возвращается. **Идея 1:**
дать каждому `run` возможность вернуть (или инструмент — объявить)
`runMask` — ч/б изображение маски изменений либо иной подход (отдельное
поле `mask` + функция маски, второй результат, overlay). Оценка — на
усмотрение при проработке (S/M). Пока живём без неё. **Идея 2:** каждый
`run` возвращает два изображения — для отображения (показывает маску или
сам результат) и для передачи в следующий инструмент (всегда результат).
Оценка — на усмотрение при проработке (S/M). Пока живём без неё. Первые
кандидаты на маску: `remove-color-from-png`, `remove-background-png`.
## UI и качество ## UI и качество
- [ ] split to parts - посмотреть вариант с toggle component - может будет - [ ] split to parts - посмотреть вариант с toggle component - может будет
удобнее чем sectioned buttons удобнее чем sectioned buttons
- [ ] чип «Маска» (`resultCard.maskToggle` на тайлах результата) — попробовать
заменить на Toggle-компонент, когда тот будет доделан (см. пункт про
визуал toggle в «Pipeline»): возможно, будет удобнее и понятнее
- [ ] маска подгружается с задержкой при нажатии на чип «Маска» — сейчас
`runMask` считается только для включённых шагов (эффект в
`SchemaToolView.svelte`). Альтернатива: считать маску всегда вместе с
результатом, по нажатию просто показывать другую картинку. Оценить
достоинства/недостатки: отклик мгновенный, но лишний проход по пикселям на
каждый ран каждого шага с `runMask` (на больших изображениях — заметная
цена за функцию, которую могут не открыть)
- [ ] неудобно настраивать ползунками, некоторые инструменты удобнее настроить - [ ] неудобно настраивать ползунками, некоторые инструменты удобнее настроить
через drag-n-drop. звучит сложно, но надо придумать процесс через drag-n-drop. звучит сложно, но надо придумать процесс
- [ ] fav: текущий ui слишком дерганный — при клике на звёздочку элементы - [ ] fav: текущий ui слишком дерганный — при клике на звёздочку элементы
@@ -176,8 +185,6 @@
в дизайне, а где должен быть 0 в дизайне, а где должен быть 0
- [ ] Component-тесты `SchemaToolView`/`SchemaPreview` требуют отдельного - [ ] Component-тесты `SchemaToolView`/`SchemaPreview` требуют отдельного
решения: node Vitest не имеет DOM/canvas, а e2e уже покрывает поведение. решения: node Vitest не имеет DOM/canvas, а e2e уже покрывает поведение.
- [ ] Ошибка декодирования изображения из `createImageBitmap` пока показывается
сырым сообщением браузера; нужен `ToolError`-маппер и i18n-ключ.
- [ ] **Идея 13. Контраст текста на акцентных кнопках — частично закрыт**. Тона - [ ] **Идея 13. Контраст текста на акцентных кнопках — частично закрыт**. Тона
`--color-main`/`--color-accent` подняты под текущий порог `--color-main`/`--color-accent` подняты под текущий порог
`palette.test.ts`; targeted-тест проходит. Light-тема всё ещё ниже AA 4.5 `palette.test.ts`; targeted-тест проходит. Light-тема всё ещё ниже AA 4.5
@@ -210,10 +217,6 @@
`tool-flows.spec.ts`, `schema-tool-strings.test.ts`) `tool-flows.spec.ts`, `schema-tool-strings.test.ts`)
- [ ] можно ли в тестах получать i18n ключ вместо текста? можно ли написать lint - [ ] можно ли в тестах получать i18n ключ вместо текста? можно ли написать lint
правило такое (запретить toContain без i18n) правило такое (запретить toContain без i18n)
- [ ] убрать в тестах все нестандартные символы `→`, длинные тире, русский язык
и т.д., сделать lint правило
- [ ] комментарии на английском, lint правило
- [ ] уменьшить количество и многословность комментариев (lint правило?)
## SEO/GEO ## SEO/GEO
+11 -2
View File
@@ -186,7 +186,7 @@
`+layout.svelte`), а не литералом `/og/…`: под `BASE_PATH` корневой путь `+layout.svelte`), а не литералом `/og/…`: под `BASE_PATH` корневой путь
ломается. Домен для этого не нужен; при деплое на собственном домене ломается. Домен для этого не нужен; при деплое на собственном домене
OG-картинки будут абсолютными и корректными. OG-картинки будут абсолютными и корректными.
- [ ] **[S1f]** Download-кнопка с селектором формата PNG/JPG/WebP/BMP (+quality - [x] **[S1f]** Download-кнопка с селектором формата PNG/JPG/WebP/BMP (+quality
для lossy) для всех инструментов — механика `Tool.output` уже есть для lossy) для всех инструментов — механика `Tool.output` уже есть
(`docs/backlog.md`, «Идеи» №6, UX-райз «Download»). Продуктовая основа (`docs/backlog.md`, «Идеи» №6, UX-райз «Download»). Продуктовая основа
«image tools»-позиционирования: без неё сайт остаётся PNG-сайтом. Вести «image tools»-позиционирования: без неё сайт остаётся PNG-сайтом. Вести
@@ -197,7 +197,9 @@
что селектор формата его не покрывает. что селектор формата его не покрывает.
- [ ] **[S1g]** Поведение конвертеров после S1f: не предлагать в меню форматов - [ ] **[S1g]** Поведение конвертеров после S1f: не предлагать в меню форматов
формат исходника (disable) — чтобы `convert-png-to-jpg` не мог сохранить в формат исходника (disable) — чтобы `convert-png-to-jpg` не мог сохранить в
png. Существующие страницы не трогать: `convert-png-to-jpg`, png. **Под вопросом:** блок мешает легальному сценарию «сохранить
обработанный PNG / JPEG с другим качеством»; решение не принято, см.
`docs/backlog.md`. Существующие страницы не трогать: `convert-png-to-jpg`,
`convert-png-to-webp`, `png-to-bmp` остаются самостоятельными лендингами `convert-png-to-webp`, `png-to-bmp` остаются самостоятельными лендингами
со своим контентом (§5). со своим контентом (§5).
@@ -313,6 +315,13 @@
> смыслу: `round-corners-png` лежит в `alpha`, `add-text-png` и > смыслу: `round-corners-png` лежит в `alpha`, `add-text-png` и
> `watermark-tile-png` — в `text`. > `watermark-tile-png` — в `text`.
> **Открытый вопрос по интенту «сжать до N КБ».** Лимит размера теперь живёт в
> кнопке Download (`fitWithinBytes` в `web/src/lib/core/io.ts`); интенты вида
> «compress png to 500kb / 1mb» — high-volume и лимит-ориентированные, селектор
> в Download их не закрывает как посадочная. Решение (отдельная страница / приём
> в контенте существующих / ничего) требует вордстата; имя страницы не выбрано.
> Прорабатывать вместе с вопросом «из формата в PNG» выше.
> **Открытый вопрос по интентам «из формата в PNG».** У конкурента есть > **Открытый вопрос по интентам «из формата в PNG».** У конкурента есть
> `jpg-to-png`, `webp-to-png`, `gif-to-png` как отдельные страницы; у нас таких > `jpg-to-png`, `webp-to-png`, `gif-to-png` как отдельные страницы; у нас таких
> инструментов нет и создавать их не хочется — загрузка файла уже принимает > инструментов нет и создавать их не хочется — загрузка файла уже принимает
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "easy-png-tools", "name": "easy-png-tools",
"version": "0.4.0", "version": "0.5.0",
"description": "", "description": "",
"main": "index.js", "main": "index.js",
"scripts": { "scripts": {
+2 -2
View File
@@ -59,7 +59,7 @@ function findChunk(png: Buffer, type: string): number {
throw new Error(`PNG chunk ${type} not found`); throw new Error(`PNG chunk ${type} not found`);
} }
/** Строит валидный RGBA PNG в рантайме (8-bit, фильтр type 0, deflate/zlib). */ /** Builds a valid RGBA PNG at runtime (8-bit, filter type 0, deflate/zlib). */
export function makePng( export function makePng(
width: number, width: number,
height: number, height: number,
@@ -279,7 +279,7 @@ export const svgMarkup =
const PIXEL_BYTES = (r: number, g: number, b: number, a: number): string => const PIXEL_BYTES = (r: number, g: number, b: number, a: number): string =>
`${r} ${g} ${b} ${a}`; `${r} ${g} ${b} ${a}`;
/** Строка для bytes-to-png / rgb-values-to-png, ровно 32 пикселя в ширину по умолчанию. */ /** Input row for bytes-to-png / rgb-values-to-png, 32 pixels per line by default. */
export function pixelRow( export function pixelRow(
count: number, count: number,
rgba: [number, number, number, number], rgba: [number, number, number, number],
+8
View File
@@ -0,0 +1,8 @@
import { ru } from "../../src/lib/i18n/ru";
/** RU page title from the dictionary; RU text is never hardcoded in specs. */
export function ruPageTitle(slug: string): string {
const title = ru.pages?.[slug]?.title;
if (!title) throw new Error(`ru dictionary has no pages.${slug}.title`);
return title;
}
+4 -4
View File
@@ -76,8 +76,8 @@ export async function renderText(page: Page, value: string): Promise<void> {
} }
export async function uploadImage(page: Page, file: SourceFile): Promise<void> { export async function uploadImage(page: Page, file: SourceFile): Promise<void> {
// На странице может быть два input[type=file]: дропзона + кнопка Open image; // The page can have two input[type=file]: dropzone + Open image button;
// канонический — первый (кнопка в шапке панели). // the canonical one is the first (the button in the panel header).
const input = page.locator('input[type="file"]').first(); const input = page.locator('input[type="file"]').first();
await expect(input).toBeAttached(); await expect(input).toBeAttached();
await input.setInputFiles({ await input.setInputFiles({
@@ -117,7 +117,7 @@ export async function uploadViaDrop(
} }
export async function downloadResultFile(page: Page): Promise<Download> { export async function downloadResultFile(page: Page): Promise<Download> {
// Split-кнопка: основная часть "Download" (image) или "Download ZIP" (files). // Split button: the main part is "Download" (image) or "Download ZIP" (files).
const button = page.getByRole("button", { name: /^Download( ZIP)?$/ }); const button = page.getByRole("button", { name: /^Download( ZIP)?$/ });
await expect(button).toBeVisible(); await expect(button).toBeVisible();
const [download] = await Promise.all([ const [download] = await Promise.all([
@@ -156,5 +156,5 @@ export async function expectNoErrorAlert(page: Page): Promise<void> {
} }
export async function expectNoErrors(sink: ErrorSink): Promise<void> { export async function expectNoErrors(sink: ErrorSink): Promise<void> {
expect(sink.errors, "нет console/page errors").toEqual([]); expect(sink.errors, "no console/page errors").toEqual([]);
} }
+10 -7
View File
@@ -1,23 +1,26 @@
import { expect, test } from "playwright/test"; import { expect, test } from "playwright/test";
import { pixelRow } from "./helpers/fixtures"; import { pixelRow } from "./helpers/fixtures";
import { ruPageTitle } from "./helpers/i18n";
import { openTool, toolLink, useEnglish } from "./helpers/page"; import { openTool, toolLink, useEnglish } from "./helpers/page";
test("поиск каталога находит по названию из другой локали", async ({ const flipTitleRu = ruPageTitle("flip-png");
test("catalog search finds a tool by a title from another locale", async ({
page, page,
}) => { }) => {
await useEnglish(page); await useEnglish(page);
await page.goto("/list-tools"); await page.goto("/list-tools");
const input = page.getByRole("textbox"); const input = page.getByRole("textbox");
const flipCard = toolLink(page, "flip-png"); const flipCard = toolLink(page, "flip-png");
await input.fill("отразить"); await input.fill(flipTitleRu.split(" ")[0].toLowerCase());
await expect(flipCard).toContainText("Flip PNG"); await expect(flipCard).toContainText("Flip PNG");
await page.getByRole("button", { name: "RU", exact: true }).click(); await page.getByRole("button", { name: "RU", exact: true }).click();
await expect(flipCard).toContainText("Отразить PNG"); await expect(flipCard).toContainText(flipTitleRu);
await input.fill("flip"); await input.fill("flip");
await expect(flipCard).toContainText("Отразить PNG"); await expect(flipCard).toContainText(flipTitleRu);
}); });
test("RU/EN: переключение переводит заголовок инструмента", async ({ test("RU/EN: switching the locale translates the tool heading", async ({
page, page,
}) => { }) => {
await openTool(page, "resize-png"); await openTool(page, "resize-png");
@@ -25,13 +28,13 @@ test("RU/EN: переключение переводит заголовок ин
const enBtn = page.getByRole("button", { name: "EN", exact: true }); const enBtn = page.getByRole("button", { name: "EN", exact: true });
const heading = page.getByRole("heading", { level: 1 }); const heading = page.getByRole("heading", { level: 1 });
await ruBtn.click(); await ruBtn.click();
await expect(heading).toHaveText("Изменить размер PNG"); await expect(heading).toHaveText(ruPageTitle("resize-png"));
await expect(ruBtn).toHaveAttribute("aria-pressed", "true"); await expect(ruBtn).toHaveAttribute("aria-pressed", "true");
await enBtn.click(); await enBtn.click();
await expect(heading).toHaveText("Resize PNG"); await expect(heading).toHaveText("Resize PNG");
}); });
test("ошибка run'а локализуется и переводится при смене языка", async ({ test("run error is localized and translated on language switch", async ({
page, page,
}) => { }) => {
await openTool(page, "bytes-to-png"); await openTool(page, "bytes-to-png");
+3 -3
View File
@@ -56,8 +56,8 @@ const decodeContracts: readonly DecodeContract[] = [
{ file: sixteenBitPng, rejectedIn: [] }, { file: sixteenBitPng, rejectedIn: [] },
]; ];
// На этих файлах createImageBitmap в webkit не завершается: ни результата, ни // On these files createImageBitmap in webkit never settles: no result and no
// ошибки — контракт «accept или reject» проверить нельзя. // error, so the "accept or reject" contract cannot be verified.
const webkitHangs = new Set([ const webkitHangs = new Set([
"truncated-header.png", "truncated-header.png",
"crc-bad-ancillary.png", "crc-bad-ancillary.png",
@@ -72,7 +72,7 @@ test.describe("PNG special fixtures", () => {
}) => { }) => {
test.fixme( test.fixme(
browserName === "webkit" && webkitHangs.has(file.name), browserName === "webkit" && webkitHangs.has(file.name),
"webkit createImageBitmap зависает на этом файле", "webkit createImageBitmap hangs on this file",
); );
if (rejectedIn.includes(browserName)) { if (rejectedIn.includes(browserName)) {
await expectRejected(page, file); await expectRejected(page, file);
+1 -1
View File
@@ -34,7 +34,7 @@ for (const [id, input] of [
["data-uri-to-png", `data:image/png;base64,${tinyBase64}`], ["data-uri-to-png", `data:image/png;base64,${tinyBase64}`],
["svg-to-png", svgMarkup], ["svg-to-png", svgMarkup],
] as const) { ] as const) {
test(`text input → ${id} produces result image`, async ({ page }) => { test(`text input -> ${id} produces result image`, async ({ page }) => {
const sink = trackErrors(page); const sink = trackErrors(page);
await openTool(page, id); await openTool(page, id);
await expect(async () => { await expect(async () => {
+7 -7
View File
@@ -46,7 +46,7 @@ test("resize-png keeps user-set size when another image is uploaded", async ({
await uploadImage(page, opaquePng); await uploadImage(page, opaquePng);
const width = page.getByRole("spinbutton", { name: "Width" }); const width = page.getByRole("spinbutton", { name: "Width" });
await width.fill("25"); await width.fill("25");
// keepAspect включён по умолчанию: высота пересчитана из 64×48 → 19 // keepAspect is on by default: height is recomputed from 64×48 -> 19
await expect(page.getByRole("spinbutton", { name: "Height" })).toHaveValue( await expect(page.getByRole("spinbutton", { name: "Height" })).toHaveValue(
"19", "19",
); );
@@ -157,7 +157,7 @@ test("download format selector: JPG warns about alpha and produces JPEG", async
await expect( await expect(
page.getByRole("status").filter({ hasText: /transparency/ }), page.getByRole("status").filter({ hasText: /transparency/ }),
).toBeVisible(); ).toBeVisible();
// у JPG в dropdown есть настройка качества // JPG has a quality setting in the dropdown
await rangeInput(page, "Quality").fill("10"); await rangeInput(page, "Quality").fill("10");
const { name, bytes } = await downloadResultBytes(page); const { name, bytes } = await downloadResultBytes(page);
expect(name).toBe("flip-png.jpg"); expect(name).toBe("flip-png.jpg");
@@ -238,8 +238,8 @@ test("blur: changing slider updates result image", async ({ page }) => {
}); });
test("runs without Web Worker (fallback)", async ({ browser }, testInfo) => { test("runs without Web Worker (fallback)", async ({ browser }, testInfo) => {
// newContext() не наследует use.baseURL из конфига — пробрасываем явно, // newContext() does not inherit use.baseURL from the config - pass it
// иначе относительный goto() в helpers не резолвится. // explicitly, otherwise the relative goto() in helpers does not resolve.
const context = await browser.newContext({ const context = await browser.newContext({
baseURL: testInfo.project.use.baseURL, baseURL: testInfo.project.use.baseURL,
}); });
@@ -278,7 +278,7 @@ test("chain: add step extends pipeline without reload and persists", async ({
}); });
await expect(stepTwo).toBeVisible(); await expect(stepTwo).toBeVisible();
await expect(resultImage(page)).toBeVisible(); await expect(resultImage(page)).toBeVisible();
// цепочка переживает перезагрузку (localStorage) // the chain survives a reload (localStorage)
await page.reload(); await page.reload();
await expect(stepTwo).toBeVisible(); await expect(stepTwo).toBeVisible();
await expectNoErrorAlert(page); await expectNoErrorAlert(page);
@@ -295,8 +295,8 @@ test("chain: remove step returns to a single tool", async ({ page }) => {
exact: true, exact: true,
}); });
await expect(stepTwo).toBeVisible(); await expect(stepTwo).toBeVisible();
// Клик по remove именно в карточке Grayscale: порядок шагов зависит от // Click remove in the Grayscale card specifically: step order depends on
// тайминга рендера и на разных движках может отличаться. // render timing and can differ between engines.
const card = page.getByRole("article").filter({ hasText: "Grayscale PNG" }); const card = page.getByRole("article").filter({ hasText: "Grayscale PNG" });
await card.getByLabel("Remove step").click(); await card.getByLabel("Remove step").click();
await expect(stepTwo).toHaveCount(0); await expect(stepTwo).toHaveCount(0);
+10 -10
View File
@@ -15,7 +15,7 @@ import {
type Kind = "image" | "text-out" | "verdict"; type Kind = "image" | "text-out" | "verdict";
const CASES: { id: string; kind: Kind; file?: SourceFile }[] = [ const CASES: { id: string; kind: Kind; file?: SourceFile }[] = [
// ── convert ──────────────────────────────────────────────── // -- convert
{ id: "convert-png-to-jpg", kind: "image" }, { id: "convert-png-to-jpg", kind: "image" },
{ id: "convert-png-to-webp", kind: "image" }, { id: "convert-png-to-webp", kind: "image" },
{ id: "png-to-bmp", kind: "image" }, { id: "png-to-bmp", kind: "image" },
@@ -24,7 +24,7 @@ const CASES: { id: string; kind: Kind; file?: SourceFile }[] = [
{ id: "png-to-hex", kind: "text-out" }, { id: "png-to-hex", kind: "text-out" },
{ id: "png-to-bytes", kind: "text-out" }, { id: "png-to-bytes", kind: "text-out" },
{ id: "png-to-rgb-values", kind: "text-out" }, { id: "png-to-rgb-values", kind: "text-out" },
// ── alpha ────────────────────────────────────────────────── // -- alpha
{ id: "remove-background-png", kind: "image", file: transparentPng }, { id: "remove-background-png", kind: "image", file: transparentPng },
{ id: "remove-color-from-png", kind: "image", file: transparentPng }, { id: "remove-color-from-png", kind: "image", file: transparentPng },
{ id: "round-corners-png", kind: "image", file: transparentPng }, { id: "round-corners-png", kind: "image", file: transparentPng },
@@ -45,7 +45,7 @@ const CASES: { id: string; kind: Kind; file?: SourceFile }[] = [
{ id: "remove-alpha-channel-png", kind: "image", file: transparentPng }, { id: "remove-alpha-channel-png", kind: "image", file: transparentPng },
{ id: "square-mask-png", kind: "image", file: transparentPng }, { id: "square-mask-png", kind: "image", file: transparentPng },
{ id: "star-mask-png", kind: "image", file: transparentPng }, { id: "star-mask-png", kind: "image", file: transparentPng },
// ── color ────────────────────────────────────────────────── // -- color
{ id: "grayscale-png", kind: "image" }, { id: "grayscale-png", kind: "image" },
{ id: "invert-colors-png", kind: "image" }, { id: "invert-colors-png", kind: "image" },
{ id: "sepia-png", kind: "image" }, { id: "sepia-png", kind: "image" },
@@ -70,7 +70,7 @@ const CASES: { id: string; kind: Kind; file?: SourceFile }[] = [
{ id: "png-to-hsi", kind: "image" }, { id: "png-to-hsi", kind: "image" },
{ id: "png-to-ycbcr", kind: "image" }, { id: "png-to-ycbcr", kind: "image" },
{ id: "png-to-lab", kind: "image" }, { id: "png-to-lab", kind: "image" },
// ── geometry ─────────────────────────────────────────────── // -- geometry
{ id: "flip-png", kind: "image" }, { id: "flip-png", kind: "image" },
{ id: "rotate-png", kind: "image" }, { id: "rotate-png", kind: "image" },
{ id: "rotate-free-png", kind: "image" }, { id: "rotate-free-png", kind: "image" },
@@ -87,7 +87,7 @@ const CASES: { id: string; kind: Kind; file?: SourceFile }[] = [
{ id: "change-aspect-ratio-png", kind: "image" }, { id: "change-aspect-ratio-png", kind: "image" },
{ id: "symmetric-copy-png", kind: "image" }, { id: "symmetric-copy-png", kind: "image" },
{ id: "shift-png", kind: "image" }, { id: "shift-png", kind: "image" },
// ── filters ──────────────────────────────────────────────── // -- filters
{ id: "blur-png", kind: "image" }, { id: "blur-png", kind: "image" },
{ id: "sharpen-png", kind: "image" }, { id: "sharpen-png", kind: "image" },
{ id: "pixelate-png", kind: "image" }, { id: "pixelate-png", kind: "image" },
@@ -96,11 +96,11 @@ const CASES: { id: string; kind: Kind; file?: SourceFile }[] = [
{ id: "silhouette-png", kind: "image" }, { id: "silhouette-png", kind: "image" },
{ id: "randomize-pixels-png", kind: "image" }, { id: "randomize-pixels-png", kind: "image" },
{ id: "jpeg-artifacts-png", kind: "image" }, { id: "jpeg-artifacts-png", kind: "image" },
// ── text ─────────────────────────────────────────────────── // -- text
{ id: "add-text-png", kind: "image" }, { id: "add-text-png", kind: "image" },
{ id: "date-stamp-png", kind: "image" }, { id: "date-stamp-png", kind: "image" },
{ id: "watermark-tile-png", kind: "image" }, { id: "watermark-tile-png", kind: "image" },
// ── analyze (image output) ───────────────────────────────── // -- analyze (image output)
{ id: "extract-color-from-png", kind: "image", file: transparentPng }, { id: "extract-color-from-png", kind: "image", file: transparentPng },
{ id: "show-transparent-png", kind: "image", file: transparentPng }, { id: "show-transparent-png", kind: "image", file: transparentPng },
{ id: "light-pixel-mask-png", kind: "image", file: transparentPng }, { id: "light-pixel-mask-png", kind: "image", file: transparentPng },
@@ -109,7 +109,7 @@ const CASES: { id: string; kind: Kind; file?: SourceFile }[] = [
{ id: "show-grayscale-pixels-png", kind: "image", file: transparentPng }, { id: "show-grayscale-pixels-png", kind: "image", file: transparentPng },
{ id: "show-color-pixels-png", kind: "image", file: transparentPng }, { id: "show-color-pixels-png", kind: "image", file: transparentPng },
{ id: "dark-pixel-mask-png", kind: "image", file: transparentPng }, { id: "dark-pixel-mask-png", kind: "image", file: transparentPng },
// ── analyze (verdict output) ─────────────────────────────── // -- analyze (verdict output)
{ id: "png-is-transparent", kind: "verdict" }, { id: "png-is-transparent", kind: "verdict" },
{ id: "png-is-grayscale", kind: "verdict" }, { id: "png-is-grayscale", kind: "verdict" },
{ id: "png-orientation", kind: "verdict" }, { id: "png-orientation", kind: "verdict" },
@@ -132,8 +132,8 @@ test.describe("smoke: tools produce output without errors", () => {
} }
}; };
// toPass: устойчивость к hydration-рейсу (ввод до гидрации SvelteKit // toPass: tolerates the hydration race (input before SvelteKit
// может не обработаться с первого раза). // hydration may not be processed on the first attempt).
await expect(async () => { await expect(async () => {
await openTool(page, id); await openTool(page, id);
await uploadImage(page, fixture ?? opaquePng); await uploadImage(page, fixture ?? opaquePng);
+28
View File
@@ -131,6 +131,34 @@ report-only (`warn`), baseline — 0 срабатываний.
- Тесты: `__tests__/no-hardcoded-user-text.test.ts` — `RuleTester` на - Тесты: `__tests__/no-hardcoded-user-text.test.ts` — `RuleTester` на
svelte-eslint-parser плюс прямые кейсы `hasUserWords`. svelte-eslint-parser плюс прямые кейсы `hasUserWords`.
## Плагин conventions: текстовые конвенции
- `conventions/ascii-only` — в файлах, заданных scope'ом конфига (`*.test.ts`,
`*.spec.ts`, `e2e/**`), только ASCII: типографика и неанглийский текст в
`describe`/`it` и ожиданиях мешают поиску и скрывают хардкод локализованных
строк. Осознанные глифы (например `×` в пайплайне) — через опцию `allowChars`
в `eslint.config.js`. Один репорт на строку.
- `conventions/comments-english` — комментарии только на английском, по **белому
списку**: комментарий обязан быть ASCII, поэтому ловятся любые письменности
(кириллица, CJK, арабский), а не один конкретный алфавит; осознанные глифы —
через опцию `allowChars`. Строковые литералы не трогаем — локализованные
значения легальны в словарях.
- `conventions/comment-format` — два мягких потолка: `maxBlockLines` (по
умолчанию 5) строк на один блок комментариев и `maxRatio` (по умолчанию 0.15)
— доля комментарных строк от непустых строк файла; density проверяется только
в файлах от `minLines` непустых строк (в конфиге 100), иначе любая шапка в
маленьком файле даёт ложное превышение. Превышение — сигнал к рефакторингу
кода, а не к сжатию комментария.
Scope правил комментариев — не только `src/`: сами плагины, `scripts/` и
`*.config.*` живут по тем же конвенциям (dogfooding). Фикстуры и тесты правил
(`__fixtures__/`, `__tests__/`) — намеренные входные кейсы и из scope исключены.
Все три правила — `error`, baseline нулевой. Русские ожидания в тестах не
хардкодятся: значения берутся из словарей `lib/i18n/` (см.
`src/lib/i18n/*.test.ts` и `e2e/helpers/i18n.ts`). Тесты правил:
`__tests__/ascii-only.test.ts`, `__tests__/comments-english.test.ts`,
`__tests__/comment-format.test.ts`.
## Токены-префиксы (целевой словарь дизайна) ## Токены-префиксы (целевой словарь дизайна)
- Цвета: `--color-*`, бренд `--brand-main` / `--brand-alt` — единственные две - Цвета: `--color-*`, бренд `--brand-main` / `--brand-alt` — единственные две
@@ -0,0 +1,79 @@
// Tests for the conventions/ascii-only rule
// (web/eslint-plugins/conventions/ascii-only.js).
//
// Pure file-content check, no filesystem: standard RuleTester with the
// tseslint parser. The scoping to test files lives in eslint.config.js, so
// filenames here are arbitrary.
import { RuleTester } from "eslint";
import tseslint from "typescript-eslint";
import { describe, it } from "vitest";
import asciiOnly from "../conventions/ascii-only.js";
import { asRuleModule } from "./helpers.js";
RuleTester.describe = describe;
RuleTester.it = it;
const tester = new RuleTester({
languageOptions: {
parser: tseslint.parser,
},
});
tester.run("ascii-only", asRuleModule(asciiOnly), {
valid: [
{ code: "const x = 1;", filename: "a.test.ts" },
{
code: "describe('resize pipeline', () => {});",
filename: "a.test.ts",
},
{
// Glyphs listed in allowChars pass.
code: "expect(label).toBe('512×512');",
filename: "a.test.ts",
options: [{ allowChars: ["×"] }],
},
],
invalid: [
{
code: "it('ресайзит картинку', () => {});",
filename: "a.test.ts",
errors: [{ messageId: "nonAscii" }],
},
{
code: "// arrow → here\nconst x = 1;",
filename: "a.test.ts",
errors: [{ messageId: "nonAscii" }],
},
{
// Em dash and ellipsis on one line collapse to a single report.
code: "it('a — b … c', () => {});",
filename: "a.test.ts",
errors: [{ messageId: "nonAscii" }],
},
{
// Two offending lines produce two reports.
code: "// über\n// café\nconst x = 1;",
filename: "a.test.ts",
errors: [{ messageId: "nonAscii" }, { messageId: "nonAscii" }],
},
{
// allowChars does not whitelist other glyphs on the same line.
code: "expect(s).toBe('512×512 → done');",
filename: "a.test.ts",
options: [{ allowChars: ["×"] }],
errors: [{ messageId: "nonAscii" }],
},
{
// Control characters (here: vertical tab) are not printable ASCII.
code: "const s = '\x0B';",
filename: "a.test.ts",
errors: [{ messageId: "nonAscii" }],
},
{
// DEL (U+007F) is not printable either.
code: "const s = '\x7F';",
filename: "a.test.ts",
errors: [{ messageId: "nonAscii" }],
},
],
});
@@ -0,0 +1,98 @@
// Tests for the conventions/comment-format rule
// (web/eslint-plugins/conventions/comment-format.js).
//
// Pure AST check (comment nodes + line counts), standard RuleTester.
import { RuleTester } from "eslint";
import tseslint from "typescript-eslint";
import { describe, it } from "vitest";
import commentFormat from "../conventions/comment-format.js";
import { asRuleModule } from "./helpers.js";
RuleTester.describe = describe;
RuleTester.it = it;
const tester = new RuleTester({
languageOptions: {
parser: tseslint.parser,
},
});
const longBlock = [
"// one",
"// two",
"// three",
"// four",
"// five",
"// six",
"const x = 1;",
].join("\n");
tester.run("comment-format", asRuleModule(commentFormat), {
// Density is off (maxRatio: 1) in the block-focused cases: tiny snippets
// are comment-dominated by construction.
valid: [
{
code: "// why\nconst x = 1;",
filename: "a.ts",
options: [{ maxRatio: 1 }],
},
{
code: "// a\n// b\n// c\n// d\n// e\nconst x = 1;",
filename: "a.ts",
options: [{ maxRatio: 1 }],
},
{
// Blank line breaks the run: two 3-line blocks, both under the cap.
code: "// a\n// b\n// c\n\n// d\n// e\n// f\nconst x = 1;",
filename: "a.ts",
options: [{ maxRatio: 1 }],
},
{
// Trailing inline comment does not extend the block above it.
code: "// a\n// b\nconst x = 1; // tail",
filename: "a.ts",
options: [{ maxRatio: 1 }],
},
{
// A wider block cap admits the same comment.
code: longBlock,
filename: "a.ts",
options: [{ maxBlockLines: 6, maxRatio: 1 }],
},
{
// Density is skipped below minLines: small files are exempt.
code: "// a\n// b\nconst x = 1;\nconst y = 2;",
filename: "a.ts",
options: [{ maxBlockLines: 5, maxRatio: 0.4, minLines: 10 }],
},
],
invalid: [
{
code: longBlock,
filename: "a.ts",
options: [{ maxRatio: 1 }],
errors: [{ messageId: "blockTooLong" }],
},
{
// A single block comment counts its full line span.
code: "/*\n1\n2\n3\n4\n5\n6\n*/\nconst x = 1;",
filename: "a.ts",
options: [{ maxRatio: 1 }],
errors: [{ messageId: "blockTooLong" }],
},
{
// Density: 2 comment lines against 2 code lines is 50%.
code: "// a\n// b\nconst x = 1;\nconst y = 2;",
filename: "a.ts",
options: [{ maxBlockLines: 5, maxRatio: 0.4 }],
errors: [{ messageId: "tooDense" }],
},
{
// Both violations in one file produce two reports.
code: longBlock + "\n// extra",
filename: "a.ts",
options: [{ maxBlockLines: 5, maxRatio: 0.5 }],
errors: [{ messageId: "blockTooLong" }, { messageId: "tooDense" }],
},
],
});
@@ -0,0 +1,96 @@
// Tests for the conventions/comments-english rule
// (web/eslint-plugins/conventions/comments-english.js).
//
// Pure AST check over comment nodes with an ASCII whitelist; string literals
// are ignored by construction (they are not comments), which the cases below
// pin down.
import { RuleTester } from "eslint";
import tseslint from "typescript-eslint";
import { describe, it } from "vitest";
import commentsEnglish from "../conventions/comments-english.js";
import { asRuleModule } from "./helpers.js";
RuleTester.describe = describe;
RuleTester.it = it;
const tester = new RuleTester({
languageOptions: {
parser: tseslint.parser,
},
});
tester.run("comments-english", asRuleModule(commentsEnglish), {
valid: [
{ code: "// resize the canvas\nconst x = 1;", filename: "a.ts" },
{ code: "/* multi\nline */\nconst x = 1;", filename: "a.ts" },
{
// Localized strings are dictionary content, not comments.
code: "const title = 'Пример';",
filename: "a.ts",
},
{
// Glyphs listed in allowChars pass.
code: "// size 512×512\nconst x = 1;",
filename: "a.ts",
options: [{ allowChars: ["×"] }],
},
{
// A tab inside a comment is legitimate whitespace.
code: "//\tnote\nconst x = 1;",
filename: "a.ts",
},
],
invalid: [
{
code: "// ресайз холста\nconst x = 1;",
filename: "a.ts",
errors: [{ messageId: "nonAscii" }],
},
{
// Whitelist catches scripts beyond Cyrillic too.
code: "// 调整画布大小\nconst x = 1;",
filename: "a.ts",
errors: [{ messageId: "nonAscii" }],
},
{
code: "// تغيير حجم اللوحة\nconst x = 1;",
filename: "a.ts",
errors: [{ messageId: "nonAscii" }],
},
{
// Typography in an otherwise English comment is still non-ASCII.
code: "// resize — then crop\nconst x = 1;",
filename: "a.ts",
errors: [{ messageId: "nonAscii" }],
},
{
code: "/* много\nстрочный */\nconst x = 1;",
filename: "a.ts",
errors: [{ messageId: "nonAscii" }],
},
{
code: "const x = 1; // хвостовой комментарий",
filename: "a.ts",
errors: [{ messageId: "nonAscii" }],
},
{
// One report per offending comment, not per file.
code: "// раз\n// два\nconst x = 1;",
filename: "a.ts",
errors: [{ messageId: "nonAscii" }, { messageId: "nonAscii" }],
},
{
// allowChars does not whitelist other glyphs in the same comment.
code: "// 512×512 → done\nconst x = 1;",
filename: "a.ts",
options: [{ allowChars: ["×"] }],
errors: [{ messageId: "nonAscii" }],
},
{
// Control characters (here: vertical tab) are not printable ASCII.
code: "// \x0B note\nconst x = 1;",
filename: "a.ts",
errors: [{ messageId: "nonAscii" }],
},
],
});
@@ -0,0 +1,61 @@
// Rule: ASCII-ONLY SOURCE (scoped to test files via eslint.config.js).
// Non-ASCII in test names and expectations hurts grep-ability and hides
// hardcoded localized strings; intentional glyphs go to the allowChars
// option. File-content based (not AST), one report per line.
// Details: web/eslint-plugins/README.md.
const DEFAULT_ALLOW = [];
export default {
meta: {
type: "suggestion",
docs: {
description:
"Require ASCII-only source in scoped files (tests); exceptions go to the allowChars option.",
category: "Conventions",
recommended: false,
},
schema: [
{
type: "object",
properties: {
allowChars: {
type: "array",
items: { type: "string", minLength: 1, maxLength: 1 },
uniqueItems: true,
},
},
additionalProperties: false,
},
],
messages: {
nonAscii:
"Non-ASCII character {{chars}} on this line. Tests are written in plain ASCII English; keep intentional glyphs in the rule's allowChars option.",
},
},
create(context) {
const allowed = new Set(context.options[0]?.allowChars ?? DEFAULT_ALLOW);
return {
Program() {
const sourceCode = context.sourceCode;
const text = sourceCode.getText();
const reportedLines = new Set();
// eslint-disable-next-line no-control-regex -- the printable-ASCII whitelist needs explicit control escapes
for (const match of text.matchAll(/[^\x09\x0A\x0D\x20-\x7E]/g)) {
const char = match[0];
if (allowed.has(char)) continue;
const loc = sourceCode.getLocFromIndex(match.index);
if (reportedLines.has(loc.line)) continue;
reportedLines.add(loc.line);
context.report({
loc,
messageId: "nonAscii",
data: {
chars: `'${char}' (U+${char.codePointAt(0).toString(16).toUpperCase().padStart(4, "0")})`,
},
});
}
},
};
},
};
@@ -0,0 +1,104 @@
// Rule: COMMENT FORMAT: bounded blocks and bounded density.
// maxBlockLines caps one comment block; maxRatio caps the share of comment
// lines in files with minLines+ non-blank lines. Warn-only tripwires:
// excess means refactor the code, not squeeze the comment.
// Details: web/eslint-plugins/README.md.
function lineSpan(comment) {
return comment.loc.end.line - comment.loc.start.line + 1;
}
export default {
meta: {
type: "suggestion",
docs: {
description:
"Cap comment block length and overall comment density per file.",
category: "Conventions",
recommended: false,
},
schema: [
{
type: "object",
properties: {
maxBlockLines: { type: "integer", minimum: 1 },
maxRatio: { type: "number", minimum: 0, maximum: 1 },
minLines: { type: "integer", minimum: 0 },
},
additionalProperties: false,
},
],
messages: {
blockTooLong:
"Comment block is {{lines}} lines (limit {{max}}). Explain 'why' briefly; move 'what' into names, structure, or a linked doc.",
tooDense:
"Comments take {{percent}}% of this file (limit {{max}}%). Prefer extracting functions/modules over narrating the code.",
},
},
create(context) {
const maxBlockLines = context.options[0]?.maxBlockLines ?? 5;
const maxRatio = context.options[0]?.maxRatio ?? 0.15;
const minLines = context.options[0]?.minLines ?? 0;
return {
Program(node) {
const sourceCode = context.sourceCode;
const comments = sourceCode.getAllComments();
if (comments.length === 0) return;
const commentLines = new Set();
let blockStart = comments[0];
let blockEndLine = comments[0].loc.end.line;
let blockLines = lineSpan(comments[0]);
const flushBlock = () => {
if (blockLines > maxBlockLines) {
context.report({
loc: blockStart.loc,
messageId: "blockTooLong",
data: { lines: String(blockLines), max: String(maxBlockLines) },
});
}
};
for (let i = 0; i < comments.length; i++) {
const comment = comments[i];
for (let l = comment.loc.start.line; l <= comment.loc.end.line; l++) {
commentLines.add(l);
}
if (i === 0) continue;
// Adjacent `//` lines form one block; anything else starts a new one.
if (
comment.type === "Line" &&
comments[i - 1].type === "Line" &&
comment.loc.start.line === blockEndLine + 1
) {
blockEndLine = comment.loc.end.line;
blockLines += 1;
} else {
flushBlock();
blockStart = comment;
blockEndLine = comment.loc.end.line;
blockLines = lineSpan(comment);
}
}
flushBlock();
const totalLines = sourceCode.lines.filter(
(l) => l.trim() !== "",
).length;
const ratio = commentLines.size / totalLines;
if (totalLines >= minLines && ratio > maxRatio) {
context.report({
loc: node.loc,
messageId: "tooDense",
data: {
percent: (ratio * 100).toFixed(1),
max: (maxRatio * 100).toFixed(1),
},
});
}
},
};
},
};
@@ -0,0 +1,63 @@
// Rule: COMMENTS IN ENGLISH ONLY. Whitelist, not blacklist: a comment must
// be plain ASCII, so Arabic/CJK/Korean are caught just like Cyrillic.
// Intentional glyphs go to the allowChars option. String literals are NOT
// checked: localized dictionary values legitimately contain other scripts.
// Details: web/eslint-plugins/README.md.
export default {
meta: {
type: "suggestion",
docs: {
description:
"Require comments to be written in English (ASCII whitelist; exceptions via allowChars).",
category: "Conventions",
recommended: false,
},
schema: [
{
type: "object",
properties: {
allowChars: {
type: "array",
items: { type: "string", minLength: 1, maxLength: 1 },
uniqueItems: true,
},
},
additionalProperties: false,
},
],
messages: {
nonAscii:
"Comment contains non-ASCII text ({{chars}}). Write comments in English; localized user-facing strings belong in lib/i18n dictionaries.",
},
},
create(context) {
const allowed = new Set(context.options[0]?.allowChars ?? []);
return {
Program() {
for (const comment of context.sourceCode.getAllComments()) {
const offenders = new Set();
for (const match of comment.value.matchAll(
// eslint-disable-next-line no-control-regex -- the printable-ASCII whitelist needs explicit control escapes
/[^\x09\x0A\x0D\x20-\x7E]/g,
)) {
if (!allowed.has(match[0])) offenders.add(match[0]);
}
if (offenders.size === 0) continue;
context.report({
loc: comment.loc,
messageId: "nonAscii",
data: {
chars: [...offenders]
.map(
(c) =>
`'${c}' (U+${c.codePointAt(0).toString(16).toUpperCase().padStart(4, "0")})`,
)
.join(", "),
},
});
}
},
};
},
};
+9 -13
View File
@@ -1,18 +1,11 @@
/** /**
* Local ESLint plugin "conventions". * Local ESLint plugin "conventions": cross-cutting code conventions the
* * recommended rule sets do not enforce. Rule details:
* Cross-cutting code conventions that the recommended rule sets don't enforce * web/eslint-plugins/README.md.
* (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 asciiOnly from "./ascii-only.js";
import commentFormat from "./comment-format.js";
import commentsEnglish from "./comments-english.js";
import interfaceProps from "./interface-props.js"; import interfaceProps from "./interface-props.js";
import noStringUnionAlias from "./no-string-union-alias.js"; import noStringUnionAlias from "./no-string-union-alias.js";
@@ -22,6 +15,9 @@ export default {
version: "0.1.0", version: "0.1.0",
}, },
rules: { rules: {
"ascii-only": asciiOnly,
"comment-format": commentFormat,
"comments-english": commentsEnglish,
"interface-props": interfaceProps, "interface-props": interfaceProps,
"no-string-union-alias": noStringUnionAlias, "no-string-union-alias": noStringUnionAlias,
}, },
@@ -1,28 +1,8 @@
// Rule: SVELTE 5 PROPS MUST USE A LOCAL `interface Props`. // Rule: SVELTE 5 PROPS MUST USE A LOCAL `interface Props` (AGENTS.md).
// // Banned: the inline generic `$props<T>()`, untyped destructuring, binding
// Convention (AGENTS.md): every typed props of a component is described by a // the whole `$props()` object, non-local props type names. Purely syntactic
// local `interface Props`, and the props are destructured with the annotation: // check; inline import('...') types are already covered by
// // @typescript-eslint/consistent-type-imports.
// 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"; import { isInsideScriptElement } from "./utils.js";
@@ -1,16 +1,7 @@
// Rule: NO STRING-LITERAL UNION TYPE ALIASES. // Rule: NO STRING-LITERAL UNION TYPE ALIASES. A `type Kind = 'a' | 'b'`
// // alias duplicates the literal set across files and derived aliases drift
// A type alias whose members are all string literals (`type Kind = 'a' | 'b'`) // from the source; prefer one `as const` object + indexed access. Only
// duplicates the literal set: the same set is re-typed in many places, and new // TSTypeAliasDeclaration is checked. Details: web/eslint-plugins/README.md.
// "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. */ /** True when the union and all its (possibly nested) members are string literals. */
function allStringLiterals(/** @type {any} */ union) { function allStringLiterals(/** @type {any} */ union) {
+4 -14
View File
@@ -16,31 +16,21 @@ export const SIZE_PROPS =
/^(width|height|min-width|max-width|min-height|max-height|padding|padding-top|padding-right|padding-bottom|padding-left|margin|margin-top|margin-right|margin-bottom|margin-left|gap|column-gap|row-gap|top|right|bottom|left|inset|font|font-size|letter-spacing|word-spacing|line-height|border-radius|border-top-left-radius|border-top-right-radius|border-bottom-left-radius|border-bottom-right-radius|border-width|border-top-width|border-right-width|border-bottom-width|border-left-width|flex-basis|background-size|background-position|border-spacing|grid-template-columns|grid-template-rows)$/; /^(width|height|min-width|max-width|min-height|max-height|padding|padding-top|padding-right|padding-bottom|padding-left|margin|margin-top|margin-right|margin-bottom|margin-left|gap|column-gap|row-gap|top|right|bottom|left|inset|font|font-size|letter-spacing|word-spacing|line-height|border-radius|border-top-left-radius|border-top-right-radius|border-bottom-left-radius|border-bottom-right-radius|border-width|border-top-width|border-right-width|border-bottom-width|border-left-width|flex-basis|background-size|background-position|border-spacing|grid-template-columns|grid-template-rows)$/;
// ===================================================================== // =====================================================================
// SHORTHAND properties that accept BOTH a color and a size. The browser // SHORTHANDS taking BOTH a color and a size: the browser assigns
// assigns their sub-properties by value type at runtime (length -> width, // sub-properties by value type, so no positional category check. Their
// color -> ...-color), so they cannot be category-checked positionally. // longhands are covered by SIZE_PROPS / COLOR_PROPS individually.
// border -> border-width + border-color
// outline -> outline-width + outline-color
// text-decoration -> text-decoration-line/-color/...
// column-rule -> column-rule-width + column-rule-color
// The longhands they expand to (border-width, border-color, ...) are already
// covered individually by SIZE_PROPS / COLOR_PROPS.
// ===================================================================== // =====================================================================
export const MIXED_PROPS = export const MIXED_PROPS =
/^(border|border-top|border-right|border-bottom|border-left|outline|text-decoration|column-rule)$/; /^(border|border-top|border-right|border-bottom|border-left|outline|text-decoration|column-rule)$/;
// ===================================================================== // =====================================================================
// Properties that carry a DURATION (ms/s) — transitions/animations. // Properties that carry a DURATION (ms/s): transitions/animations.
// ===================================================================== // =====================================================================
export const DURATION_PROPS = export const DURATION_PROPS =
/^(transition|transition-duration|transition-delay|animation|animation-duration|animation-delay)$/; /^(transition|transition-duration|transition-delay|animation|animation-duration|animation-delay)$/;
// ===================================================================== // =====================================================================
// A hardcoded COLOR literal: hex / color functions / named colors. // A hardcoded COLOR literal: hex / color functions / named colors.
// Regex legend:
// - hex: #[0-9a-fA-F]{3,8}\b — #fff / #112233 / #1234
// - funcs: (rgb|rgba|hsl|hsla|hwb|lab|lch|oklch|oklab)(
// - names: common CSS color names on a word boundary
// ===================================================================== // =====================================================================
export const COLOR_LITERAL = export const COLOR_LITERAL =
/#[0-9a-fA-F]{3,8}\b|\b(?:rgb|rgba|hsl|hsla|hwb|lab|lch|oklch|oklab)\s*\(|(?:^|\s|,|\()(?:white|black|red|green|blue|yellow|orange|purple|pink|gray|grey|silver|lime|teal|cyan|navy|maroon|olive|aqua|fuchsia|gold|indigo|violet|magenta|grey)\b/gi; /#[0-9a-fA-F]{3,8}\b|\b(?:rgb|rgba|hsl|hsla|hwb|lab|lch|oklch|oklab)\s*\(|(?:^|\s|,|\()(?:white|black|red|green|blue|yellow|orange|purple|pink|gray|grey|silver|lime|teal|cyan|navy|maroon|olive|aqua|fuchsia|gold|indigo|violet|magenta|grey)\b/gi;
@@ -1,9 +1,7 @@
// Rule: NO TOKEN-CATEGORY MISMATCH IN COMPONENTS. // Rule: NO TOKEN-CATEGORY MISMATCH IN COMPONENTS. A size property must use
// A size property (padding, gap, font-size ...) must use a SIZE token // a SIZE token (--space-*, --size-*, ...), a color property a COLOR token
// (--space-*, --size-*, --text-*, --radius-*, --bp-*, --z-*). A color property // (--color-*, --brand-*). Crossing categories (padding: var(--color-x))
// (color, background, border-color ...) must use a COLOR token // means the wrong token is being reused.
// (--color-*, --brand-*). Crossing categories (e.g. padding: var(--color-x))
// is a sign the wrong token is being reused.
import { import {
COLOR_PROPS, COLOR_PROPS,
@@ -56,12 +54,10 @@ export default {
const prop = decl.prop ?? ""; const prop = decl.prop ?? "";
const value = decl.value ?? ""; const value = decl.value ?? "";
// Mixed shorthands (border, outline, text-decoration, // Mixed shorthands (border, outline, ...) legitimately take
// column-rule) legitimately take BOTH a size and a color. // BOTH a size and a color: the browser assigns sub-properties
// The browser assigns sub-properties by value type at runtime, // by value type, not by position, so skip category checks.
// not by position, so skip category checks for them. // Their longhands are still covered individually.
// Their longhands (border-width, border-color, ...) are still
// covered individually.
if (MIXED_PROPS.test(prop)) return; if (MIXED_PROPS.test(prop)) return;
const isSizeProp = SIZE_PROPS.test(prop); const isSizeProp = SIZE_PROPS.test(prop);
@@ -1,6 +1,6 @@
// Rule: NO HARDCODED COLORS / SIZES / DURATIONS / Z-INDEX IN COMPONENTS. // Rule: NO HARDCODED COLORS / SIZES / DURATIONS / Z-INDEX IN COMPONENTS.
// Everything visual in a Svelte <style> block must come from design tokens // Everything visual in a Svelte <style> block must come from design tokens
// (var(--...)); direct literals are banned. color-mix() is banned too — its // (var(--...)); direct literals are banned. color-mix() is banned too: its
// result must be tokenized in the design CSS file. // result must be tokenized in the design CSS file.
import { import {
@@ -14,8 +14,7 @@ import {
} from "./lists.js"; } from "./lists.js";
import { getStyleNodeLoc, getStyleRoot } from "./style-context.js"; import { getStyleNodeLoc, getStyleRoot } from "./style-context.js";
// Sub-rule: stripe var(...) bodies out of a value, so tokens inside are never // Strip var(...) bodies so tokens inside are never mistaken for literals.
// mistaken for literals.
const stripVars = (value) => value.replace(/var\([^)]*\)/g, ""); const stripVars = (value) => value.replace(/var\([^)]*\)/g, "");
export default { export default {
@@ -64,16 +63,12 @@ export default {
}); });
}; };
// ===================================================================== // --- NO HARDCODED BREAKPOINTS IN @media ---
// 3) NO HARDCODED BREAKPOINTS IN @media // (max|min)-width followed by a NON-var( number is a raw length.
// @media conditions must use --bp-* tokens, not raw lengths.
// Regex: (max|min)-width followed by a NON-var(() number.
// =====================================================================
const BREAKPOINT_RE = const BREAKPOINT_RE =
/(?:(?:max|min)-width)\s*:\s*(?!var\()(\d+(?:\.\d+)?(?:px|rem|em))/gi; /(?:(?:max|min)-width)\s*:\s*(?!var\()(\d+(?:\.\d+)?(?:px|rem|em))/gi;
// Custom properties do not resolve inside @media conditions, so any // Custom properties do not resolve in @media: var(--bp-*) there is a latent bug.
// var(--bp-*) inside @media is a latent bug — the block never applies.
const VAR_IN_MEDIA_RE = /var\(\s*(--[a-zA-Z0-9-]+)/g; const VAR_IN_MEDIA_RE = /var\(\s*(--[a-zA-Z0-9-]+)/g;
// Sub-rule 6: color-mix() is banned anywhere in a component style. // Sub-rule 6: color-mix() is banned anywhere in a component style.
@@ -123,12 +118,10 @@ export default {
// Only inspect known layout properties from here on. // Only inspect known layout properties from here on.
if (!COLOR_PROPS.test(prop) && !SIZE_PROPS.test(prop)) return; if (!COLOR_PROPS.test(prop) && !SIZE_PROPS.test(prop)) return;
// Sub-rule 2: sizes. // Sub-rule 2: sizes. Fire only on a real px/rem/em token:
// Fire only on a real remaining px/rem/em token — percentages // percentages and unitless values stay legal, a 0px reset is
// (width: 80%) and unitless values (line-height: 1.5) stay legal. // the only legal absolute length. MIXED_PROPS shorthands are
// The only legal absolute length is a 0px reset line. // checked for their SIZE half too.
// MIXED_PROPS (border/outline shorthands) are checked for their
// SIZE half too — a border width must come from var(--size-*).
if (SIZE_PROPS.test(prop) || MIXED_PROPS.test(prop)) { if (SIZE_PROPS.test(prop) || MIXED_PROPS.test(prop)) {
const withoutVars = stripVars(value); const withoutVars = stripVars(value);
const reported = new Set(); const reported = new Set();
@@ -141,9 +134,8 @@ export default {
} }
} }
// Sub-rule 1: colors. // Sub-rule 1: colors. After stripping allowed keywords
// Remaining value must be only allowed keywords // (currentColor/transparent/inherit/none/0), any color
// (currentColor/transparent/inherit/none/0); otherwise a color
// literal is reported. // literal is reported.
if (COLOR_PROPS.test(prop)) { if (COLOR_PROPS.test(prop)) {
const withoutVars = stripVars(value); const withoutVars = stripVars(value);
@@ -1,10 +1,8 @@
// Rule: NO DESIGN PRIMITIVE IN COMPONENT TOKEN DEFINITIONS (variant A). // Rule: NO DESIGN PRIMITIVE IN COMPONENT TOKEN DEFINITIONS (variant A).
// A custom property may be DEFINED inside a component's <style> block, but its // A custom property may be DEFINED in a component's <style>, but its value
// value must not introduce a design primitive: a raw color literal (hex/rgb/ // must not introduce a design primitive (raw color, absolute size): the
// oklch/named) or an absolute size (px/rem/em). This keeps the design surface // design surface stays in app.css, while local DERIVED variables (var(),
// (colors/sizes) a single source of truth in app.css, while still allowing // calc(), unitless ratios) remain legitimate.
// local DERIVED variables built from tokens: var(--...), calc(), unitless
// ratios (--ratio: 1.5) — those are legitimate component-local state.
import { COLOR_LITERAL, FORBIDDEN_SIZE_TOKEN } from "./lists.js"; import { COLOR_LITERAL, FORBIDDEN_SIZE_TOKEN } from "./lists.js";
import { getStyleNodeLoc, getStyleRoot } from "./style-context.js"; import { getStyleNodeLoc, getStyleRoot } from "./style-context.js";
@@ -57,7 +55,7 @@ export default {
const primitive = stripVars(value); const primitive = stripVars(value);
// A color literal in the definition → primitive. // A color literal in the definition is a primitive.
const colorHit = primitive.match(COLOR_LITERAL); const colorHit = primitive.match(COLOR_LITERAL);
if (colorHit) { if (colorHit) {
report(decl, "tokenPrimitive", { report(decl, "tokenPrimitive", {
@@ -67,7 +65,7 @@ export default {
return; return;
} }
// An absolute size (px/rem/em) in the definition → primitive. // An absolute size (px/rem/em) in the definition is a primitive.
const sizeHit = primitive.match(FORBIDDEN_SIZE_TOKEN); const sizeHit = primitive.match(FORBIDDEN_SIZE_TOKEN);
if (sizeHit) { if (sizeHit) {
report(decl, "tokenPrimitive", { report(decl, "tokenPrimitive", {
@@ -10,7 +10,7 @@ import { getStyleNodeLoc, getStyleRoot } from "./style-context.js";
// A custom property DEFINITION: "--x:" followed by a colon. // A custom property DEFINITION: "--x:" followed by a colon.
const DEFINED_RE = /--[\w-]+(?=\s*:)/g; const DEFINED_RE = /--[\w-]+(?=\s*:)/g;
// A var() REFERENCE (primary argument only — fallbacks are optional overrides). // A var() REFERENCE (primary argument only; fallbacks are optional overrides).
const VAR_REF_RE = /var\(\s*(--[\w-]+)/g; const VAR_REF_RE = /var\(\s*(--[\w-]+)/g;
const definedCache = new Map(); const definedCache = new Map();
+9 -33
View File
@@ -1,30 +1,7 @@
/** /**
* Cross-locale dictionary linter (i18n/dict-consistency). * Cross-locale dictionary linter: each dict in lib/i18n/ is checked against
* * ALL others (key parity, empty values, placeholders), warn-only.
* Each locale dict in `lib/i18n/` is checked against ALL the others (not just * Full semantics and options: web/eslint-plugins/README.md.
* against the base locale). The set of locales comes from `LOCALES` in the
* sibling `dict.ts`, so a new locale is picked up automatically — no rule
* changes needed. The rule only ever warns (a missing translation must not
* break the build) — see docs/plan-preview-i18n.md §Фаза 8.
*
* The diagnostic lives where the fix lives — each file reports its OWN gaps:
* - missing keys: the union of all keys is collected from the on-disk dicts;
* whatever the current file lacks is reported here (a missing whole
* section is reported once, not per descendant key);
* - empty values: `""` or whitespace-only values;
* - placeholders: for a shared key the `{name}` set is compared against the
* canonical set (the one shared by most locales, ties broken by BASE_LOCALE
* then LOCALES order) — catches a variable lost in translation exactly once,
* even when only one locale deviates.
*
* The rule self-filters: files whose basename is not one of LOCALES are
* ignored, so it can be attached to the whole `lib/i18n/` directory.
*
* Options (object, all optional):
* - allowPaths: dot-path keys excluded from every check.
* - baseLocaleFallback: patterns whose missing keys are allowed in BASE_LOCALE.
* - ignoreMissingPatterns: patterns temporarily excluded from missing-key
* checks.
*/ */
import fs from "node:fs"; import fs from "node:fs";
import path from "node:path"; import path from "node:path";
@@ -54,7 +31,7 @@ function unwrap(node) {
} }
/** /**
* The exported dict `export const <name>: Dict = {...}` — the declarator id * The exported dict `export const <name>: Dict = {...}`: the declarator id
* (for reporting) and the object literal, or null. * (for reporting) and the object literal, or null.
*/ */
function findDictExport(ast) { function findDictExport(ast) {
@@ -109,7 +86,7 @@ function findLocalesList(ast) {
/** /**
* String literal node. The parser emits `Literal` in some ESTree versions and * String literal node. The parser emits `Literal` in some ESTree versions and
* `StringLiteral` in others — accept both. * `StringLiteral` in others; accept both.
*/ */
function isStringLiteral(node) { function isStringLiteral(node) {
if (!node) return false; if (!node) return false;
@@ -148,10 +125,9 @@ function propKey(prop) {
} }
/** /**
* Flatten an exported dict object into `path -> { node, kind, value }`. * Flatten an exported dict into `path -> { node, kind, value }`. Computed
* Computed properties are skipped; intermediate objects are recorded too, so a * properties are skipped; intermediate objects are recorded too, so a
* missing whole section is reported once (children of a missing path are * missing whole section is reported once.
* skipped when reporting).
*/ */
function buildKeyMap(objectNode) { function buildKeyMap(objectNode) {
const map = new Map(); const map = new Map();
@@ -226,7 +202,7 @@ function readDir(dir) {
for (const locale of locales) { for (const locale of locales) {
const file = path.join(dir, `${locale}.ts`); const file = path.join(dir, `${locale}.ts`);
if (!fs.existsSync(file)) { if (!fs.existsSync(file)) {
// Declared in LOCALES but not yet translated — treated as an empty // Declared in LOCALES but not yet translated; treated as an empty
// dict so every key it lacks reports `missing key ... in <locale>`. // dict so every key it lacks reports `missing key ... in <locale>`.
dicts.push({ locale, map: new Map() }); dicts.push({ locale, map: new Map() });
continue; continue;
+3 -7
View File
@@ -1,11 +1,7 @@
/** /**
* Local ESLint plugin "i18n". * Local ESLint plugin "i18n": cross-locale dictionary hygiene
* * (dict-consistency) and no hardcoded user text in templates. Warn-only by
* Cross-locale dictionary hygiene (i18n/dict-consistency): every locale dict in * design. Rule details: web/eslint-plugins/README.md.
* `lib/i18n/` is compared against all the others — key parity, empty values,
* `{placeholder}` parity — warn-only by design (a missing translation must not
* break the build). The set of locales comes from `LOCALES` in `dict.ts`, so
* new locales are picked up automatically.
*/ */
import dictConsistency from "./dict-consistency.js"; import dictConsistency from "./dict-consistency.js";
import noHardcodedUserText from "./no-hardcoded-user-text.js"; import noHardcodedUserText from "./no-hardcoded-user-text.js";
@@ -1,19 +1,7 @@
// Rule: NO HARDCODED USER TEXT IN SVELTE TEMPLATES. // Rule: NO HARDCODED USER TEXT IN SVELTE TEMPLATES. Every user-visible
// // string must come from the dictionaries via t(); a literal silently ships
// Every user-visible string must come from the dictionaries via t(); a literal in // English to the RU locale. Warn-only report-only pilot. Exclusion list
// the template silently ships English to the RU locale. Warn-only by design // (brand, units, registry strings, /kit): web/eslint-plugins/README.md.
// (report-only pilot), so the baseline stays green while the rule is calibrated.
//
// What is NOT reported (the pilot's exclusion list, from the plan):
// - brand and version strings ("easy-png-tools", "v0.1.0");
// - formats, file names and units ("PNG", "result.png", "512×512", "px", "%");
// - X/Y axis markers and other one-letter technical markers;
// - registry source strings (ALL CAPS identifiers such as "BACKGROUND");
// - /kit, which is a component gallery with deliberately hardcoded labels.
//
// Detection surface: SvelteText nodes (including the text parts of mixed content)
// and string-literal values of user-facing attributes. Values written as
// expressions ({...}) are not inspected — the value is computed elsewhere.
import { USER_TEXT_ATTRS } from "./lists.js"; import { USER_TEXT_ATTRS } from "./lists.js";
+3 -6
View File
@@ -1,10 +1,7 @@
/** /**
* Local ESLint plugin "design-tokens". * Local ESLint plugin "design-tokens": hardcoded colors/sizes are banned in
* * Svelte <style> blocks, everything comes from CSS tokens in app.css.
* Goal: a single source of truth for design. In Svelte components, hardcoded * Rule details: web/eslint-plugins/README.md.
* colors and sizes are banned; everything must come from CSS variables (tokens
* in app.css). Rules inspect the postcss AST of Svelte <style> blocks
* exposed by svelte-eslint-parser.
*/ */
import noCategoryMismatch from "./design-tokens/no-category-mismatch.js"; import noCategoryMismatch from "./design-tokens/no-category-mismatch.js";
import noHardcodedInSvelte from "./design-tokens/no-hardcoded-in-svelte.js"; import noHardcodedInSvelte from "./design-tokens/no-hardcoded-in-svelte.js";
+97 -38
View File
@@ -8,28 +8,27 @@ import designTokens from "./eslint-plugins/index.js";
import conventionsPlugin from "./eslint-plugins/conventions/index.js"; import conventionsPlugin from "./eslint-plugins/conventions/index.js";
import i18nPlugin from "./eslint-plugins/i18n/index.js"; import i18nPlugin from "./eslint-plugins/i18n/index.js";
// Production-UI: к нему применяются полные recommended-наборы. // Production UI: the full recommended sets apply to it.
const productionCode = ["**/src/lib/components/**", "**/src/routes/**"]; const productionCode = ["**/src/lib/components/**", "**/src/routes/**"];
// Инфраструктура линтинга: сами плагины, скрипты и конфиги. Это не // Lint tooling: the plugins, gate scripts and configs. Not product code,
// продуктовый код, но всё это влияет на гейты, поэтому к нему тоже // but it feeds the gates, so the base JS recommended set applies; the full
// применяется базовый JS recommended. Полные TS/svelte-наборы здесь не // TS/svelte sets do not (.mjs/.js only).
// подключаются: файлы .mjs и .js проверяются только базовым набором.
const toolingFiles = [ const toolingFiles = [
"**/eslint-plugins/**/*.js", "**/eslint-plugins/**/*.js",
"**/scripts/**/*.mjs", "**/scripts/**/*.mjs",
"*.config.js", "*.config.js",
]; ];
// Полные recommended-наборы — только на production-код (см. ниже, блок перед prettier). // Full recommended sets apply to production code only (block before prettier).
const jsRecommended = Array.isArray(js.configs.recommended) const jsRecommended = Array.isArray(js.configs.recommended)
? js.configs.recommended ? js.configs.recommended
: [js.configs.recommended]; : [js.configs.recommended];
const svelteRecommended = Array.isArray(svelte.configs["flat/recommended"]) const svelteRecommended = Array.isArray(svelte.configs["flat/recommended"])
? svelte.configs["flat/recommended"] ? svelte.configs["flat/recommended"]
: [svelte.configs["flat/recommended"]]; : [svelte.configs["flat/recommended"]];
// Svelte-рекомендации применяем только к .svelte-файлам production-кода, иначе // Svelte recommended targets production .svelte files only, otherwise
// svelte-eslint-parser "съедает" обычные .ts в тех же папках (напр. +page.ts). // svelte-eslint-parser swallows plain .ts in the same folders (+page.ts).
const productionSvelte = [ const productionSvelte = [
"**/src/lib/components/**/*.svelte", "**/src/lib/components/**/*.svelte",
"**/src/routes/**/*.svelte", "**/src/routes/**/*.svelte",
@@ -45,7 +44,7 @@ export default tseslint.config(
"**/static/**", "**/static/**",
], ],
}, },
// Минимально на весь код: парсинг TS/Svelte + запрет инлайн-тип-импортов. // Minimum on all code: TS/Svelte parsing + ban on inline type imports.
{ {
files: ["**/*.ts", "**/*.svelte.ts", "**/*.svelte.js", "**/*.svelte"], files: ["**/*.ts", "**/*.svelte.ts", "**/*.svelte.js", "**/*.svelte"],
languageOptions: { languageOptions: {
@@ -59,17 +58,18 @@ export default tseslint.config(
"vitest.config.ts", "vitest.config.ts",
"eslint.config.js", "eslint.config.js",
"playwright.config.ts", "playwright.config.ts",
// e2e покрыты e2e/tsconfig.json. // e2e are covered by e2e/tsconfig.json. Lint-rule tests and
// Тесты и фикстуры кастомных линт-правил лежат вне src/ (не в // fixtures live outside src/, so typed parsing resolves them
// tsconfig), поэтому для типизированного парсинга резолвятся // via the default project; tseslint bans `**` here.
// через default-проект. Перечисляются точечно: `**` в
// allowDefaultProject запрещён tseslint.
"eslint-plugins/__tests__/design-tokens.test.ts", "eslint-plugins/__tests__/design-tokens.test.ts",
"eslint-plugins/__tests__/interface-props.test.ts", "eslint-plugins/__tests__/interface-props.test.ts",
"eslint-plugins/__tests__/no-string-union-alias.test.ts", "eslint-plugins/__tests__/no-string-union-alias.test.ts",
"eslint-plugins/__tests__/helpers.ts", "eslint-plugins/__tests__/helpers.ts",
"eslint-plugins/__tests__/dict-consistency.test.ts", "eslint-plugins/__tests__/dict-consistency.test.ts",
"eslint-plugins/__tests__/no-hardcoded-user-text.test.ts", "eslint-plugins/__tests__/no-hardcoded-user-text.test.ts",
"eslint-plugins/__tests__/ascii-only.test.ts",
"eslint-plugins/__tests__/comments-english.test.ts",
"eslint-plugins/__tests__/comment-format.test.ts",
"eslint-plugins/__fixtures__/src/lib/i18n/dict.ts", "eslint-plugins/__fixtures__/src/lib/i18n/dict.ts",
"eslint-plugins/__fixtures__/src/lib/i18n/en.ts", "eslint-plugins/__fixtures__/src/lib/i18n/en.ts",
"eslint-plugins/__fixtures__/src/lib/i18n/ru.ts", "eslint-plugins/__fixtures__/src/lib/i18n/ru.ts",
@@ -90,8 +90,8 @@ export default tseslint.config(
"@typescript-eslint": tseslint.plugin, "@typescript-eslint": tseslint.plugin,
}, },
rules: { rules: {
// Инлайн-тип-импорты (import('x').Y) запрещены — только // Inline type imports (import('x').Y) are banned; only
// `import type { Y } from 'x'` наверху файла. // `import type { Y } from 'x'` at the top of the file.
"@typescript-eslint/consistent-type-imports": [ "@typescript-eslint/consistent-type-imports": [
"error", "error",
{ prefer: "type-imports", fixStyle: "separate-type-imports" }, { prefer: "type-imports", fixStyle: "separate-type-imports" },
@@ -116,8 +116,7 @@ export default tseslint.config(
"design-tokens": designTokens, "design-tokens": designTokens,
}, },
}, },
// Правило дизайн-токенов: запрет хардкода цветов/размеров в style-блоках. // Design-token rules: no hardcoded colors/sizes in style blocks.
// Применяется к новому коду редизайна.
{ {
files: productionSvelte, files: productionSvelte,
plugins: { plugins: {
@@ -130,8 +129,8 @@ export default tseslint.config(
"design-tokens/no-undefined-in-svelte": "error", "design-tokens/no-undefined-in-svelte": "error",
}, },
}, },
// Конвенция Svelte 5: пропсы через локальный `interface Props` + // Svelte 5 convention: props via a local `interface Props` +
// `let {...}: Props = $props()` (плагин conventions/interface-props). // `let {...}: Props = $props()` (conventions/interface-props).
{ {
files: productionSvelte, files: productionSvelte,
plugins: { plugins: {
@@ -141,8 +140,8 @@ export default tseslint.config(
"conventions/interface-props": "error", "conventions/interface-props": "error",
}, },
}, },
// Запрет строковых union-алиасов в пользу `as const` объектов // Ban string-literal union aliases in favor of `as const` objects
// (плагин conventions/no-string-union-alias). TS и Svelte-скрипты production-кода. // (conventions/no-string-union-alias).
{ {
files: productionCode, files: productionCode,
plugins: { plugins: {
@@ -152,11 +151,9 @@ export default tseslint.config(
"conventions/no-string-union-alias": "error", "conventions/no-string-union-alias": "error",
}, },
}, },
// Кросс-языковой линтер словарей (плагин i18n/dict-consistency) и запрет // Cross-locale dictionary linter (i18n/dict-consistency). Reports warn;
// захардкоженного пользовательского текста (i18n/no-hardcoded-user-text). // `lint:all` makes any new warning blocking via --max-warnings=0. The
// Правила сообщают warning, а `lint:all` превращает новые warnings в // locale list comes from LOCALES in lib/i18n/dict.ts.
// blocking через --max-warnings=0. Список локалей берётся из LOCALES в
// lib/i18n/dict.ts; новые локали подхватываются автоматически.
{ {
files: ["**/src/lib/i18n/*.ts"], files: ["**/src/lib/i18n/*.ts"],
plugins: { plugins: {
@@ -173,9 +170,9 @@ export default tseslint.config(
}, },
}, },
{ {
// Хардкод пользовательского текста ловим и в components, и в routes: // Hardcoded user text is caught in components and routes alike:
// `+page.svelte`/`+layout.svelte` — такой же production-UI, иначе // `+page.svelte` is production UI too, a hardcoded string silently
// захардкоженная строка молча уедет в RU-локаль. // ships in the RU locale.
files: productionSvelte, files: productionSvelte,
plugins: { plugins: {
i18n: i18nPlugin, i18n: i18nPlugin,
@@ -184,21 +181,83 @@ export default tseslint.config(
"i18n/no-hardcoded-user-text": [ "i18n/no-hardcoded-user-text": [
"warn", "warn",
{ {
// Название продукта — бренд, а не переводимый текст. // The product name is a brand, not translatable text.
allowWords: ["easy-png-tools"], allowWords: ["easy-png-tools"],
}, },
], ],
}, },
}, },
{ {
// Исключение для /kit — витрина компонентов с намеренно латинскими // /kit exception: a showcase with intentionally Latin labels. Separate
// подписями. Отдельный объект, а не негативный glob в `files` выше: // object: negative globs in `files` do not narrow a rule's scope.
// негативные паттерны не сужают область действия правила.
files: ["**/src/routes/kit/**/*.svelte"], files: ["**/src/routes/kit/**/*.svelte"],
rules: { rules: {
"i18n/no-hardcoded-user-text": "off", "i18n/no-hardcoded-user-text": "off",
}, },
}, },
// Tests are written in ASCII English: typography and non-English prose in
// describe/it hurt grep-ability and hide hardcoded localized strings
// (expected values come from the dictionaries, not literals).
{
files: ["**/*.test.ts", "**/*.spec.ts", "**/e2e/**/*.ts"],
plugins: {
conventions: conventionsPlugin,
},
rules: {
// allowChars: U+00D7 is intentional - the pipeline emits sizes with
// it and tests must assert that exact output.
"conventions/ascii-only": ["error", { allowChars: ["×"] }],
},
},
// Comments in English, in short blocks, without turning a file into an
// essay (conventions/comments-english + conventions/comment-format).
// Excess is a cue to refactor code, not squeeze comments.
{
files: [
"**/src/**/*.ts",
"**/src/**/*.svelte",
// Dogfooding: lint rules, gate scripts and configs follow the same
// comment conventions as production code.
"**/eslint-plugins/**/*.js",
"**/scripts/**/*.mjs",
"**/*.config.js",
"**/*.config.ts",
"**/*.config.mjs",
],
plugins: {
conventions: conventionsPlugin,
},
rules: {
"conventions/comments-english": "error",
"conventions/comment-format": [
"error",
// minLines: density skips small files (any header would trip it).
{ maxBlockLines: 5, maxRatio: 0.15, minLines: 100 },
],
},
},
{
// Lint-rule fixtures are intentional stubs (including non-ASCII and
// non-English comments as input cases); the conventions do not apply.
files: [
"**/eslint-plugins/__fixtures__/**",
"**/eslint-plugins/__tests__/**",
],
rules: {
"conventions/ascii-only": "off",
"conventions/comments-english": "off",
"conventions/comment-format": "off",
},
},
{
files: productionCode,
rules: {
"max-lines-per-function": [
"warn",
{ max: 100, skipBlankLines: true, skipComments: true },
],
},
},
{ {
files: toolingFiles, files: toolingFiles,
languageOptions: { languageOptions: {
@@ -209,7 +268,7 @@ export default tseslint.config(
}, },
}, },
}, },
// Полные recommended-наборы — только на production-код. // Full recommended sets: production code only.
...[ ...[
...jsRecommended.map((cfg) => ({ ...jsRecommended.map((cfg) => ({
...cfg, ...cfg,
@@ -229,14 +288,14 @@ export default tseslint.config(
})), })),
], ],
// В Svelte 5 пропсы деструктурируются через `let` (конвенция документации и // In Svelte 5 props are destructured via `let` (docs convention and our
// наш AGENTS.md), поэтому prefer-const на них — ложноположительный. // AGENTS.md), so prefer-const is a false positive on them.
{ {
files: ["**/*.svelte"], files: ["**/*.svelte"],
rules: { rules: {
"prefer-const": "off", "prefer-const": "off",
}, },
}, },
// prettier — последним, чтобы гасить форматирующие правила из recommended. // prettier goes last to silence formatting rules from the recommended sets.
prettier, prettier,
); );
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "web", "name": "web",
"private": true, "private": true,
"version": "0.4.0", "version": "0.5.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite dev", "dev": "vite dev",
+3 -3
View File
@@ -44,9 +44,9 @@ export default defineConfig({
{ {
name: "webkit", name: "webkit",
testMatch: browserContractTests, testMatch: browserContractTests,
// webkit под Windows заметно медленнее и под нагрузкой случайно // WebKit on Windows is noticeably slower and under load randomly
// превышает любые таймауты (каждый прогон — разные тесты): запас по // exceeds any timeout (different tests each run): timeout headroom
// таймаутам + retries против флака. // plus retries against flakiness.
timeout: 120_000, timeout: 120_000,
expect: { timeout: 30_000 }, expect: { timeout: 30_000 },
retries: 2, retries: 2,
+3 -3
View File
@@ -1,6 +1,6 @@
// Генерирует static/og/default.png — дефолтная OG-карточка 1200×630. // Generates static/og/default.png: the default 1200x630 OG card.
// Запуск: pnpm --dir web gen:og. Карточка рисуется в браузере (текст, иконки), // Run: pnpm --dir web gen:og. The card is rendered in a browser (text,
// per-tool карточки — этим же harness позже (docs/plan-seo.md S1e). // icons); per-tool cards will reuse this harness (docs/plan-seo.md S1e).
import { mkdirSync } from "node:fs"; import { mkdirSync } from "node:fs";
import { chromium } from "playwright"; import { chromium } from "playwright";
+4 -12
View File
@@ -4,18 +4,10 @@ import { fileURLToPath } from "node:url";
const WEB_DIR = fileURLToPath(new URL("../", import.meta.url)); const WEB_DIR = fileURLToPath(new URL("../", import.meta.url));
const node = process.execPath; const node = process.execPath;
// Every lint layer of the redesign, in one command. Each step runs even if a // Every lint layer in one command. Each step runs even if a previous one
// previous one fails; the exit code is nonzero if any did. // fails; the exit code is nonzero if any did. Children inherit our stdout FD
// // directly (no pipes), so `> out.txt` catches every byte; stylelint paints
// Stream wiring: children run with stdio ["ignore", 1, 1] — both stdout and // even to a non-TTY, hence the explicit --no-color there.
// stderr are bound to OUR stdout file descriptor (shell `2>&1`). It is real FD
// inheritance, no pipes or buffering, so `> css-lint.txt` catches every byte.
// Color decisions are then the tool's own against the actual destination:
// a terminal gets colors, a redirected file gets plain text.
//
// One quirk: stylelint prints its report to stderr and paints it even when the
// destination is not a TTY. So for a non-TTY destination we pass its official
// --no-color explicitly.
const colorFlag = process.stdout.isTTY ? [] : ["--no-color"]; const colorFlag = process.stdout.isTTY ? [] : ["--no-color"];
const bins = { const bins = {
+4 -13
View File
@@ -1,16 +1,7 @@
// postcss-hct: PostCSS plugin that lets design tokens be authored as hct() and // postcss-hct: PostCSS plugin that lets design tokens be authored as hct()
// emits plain sRGB hex colors. // (literal, `from var(...)` seed, channel math) and emits sRGB hex. `from`
// // seeds resolve from sibling custom properties (#hex, oklch(), rgb(), hct());
// Supported syntax (Material Design 3 HCT; hue 0–360, chroma 0–~120, tone 0–100): // anything unresolvable is left as-is. HCT stays the single source of truth.
//
// --color-success: hct(155 40 55); // literal
// --color-muted: hct(from var(--brand-main) h 6 97); // take hue from seed
// --brand-alt: hct(from var(--brand-main) h c calc(t + 36)); // channel math
//
// `from` seeds are resolved from sibling custom properties in the same
// stylesheet (:root tokens plus the current rule's own tokens). Seeds may be
// #hex, oklch(), rgb() or another hct(). Anything the plugin cannot resolve is
// left as-is. The emitter is sRGB hex; HCT stays the single source of truth.
import { Color } from "@panmdaa/colors"; import { Color } from "@panmdaa/colors";
+4 -4
View File
@@ -1,6 +1,6 @@
// Статический сервер для e2e (Playwright webServer). Раздаёт web/build // Static server for e2e (Playwright webServer). Serves web/build
// (adapter-static): /foo -> foo.html | foo/index.html, MIME по расширению. // (adapter-static): /foo -> foo.html | foo/index.html, MIME by extension.
// Запуск: pnpm build && node scripts/serve-static.mjs --port 4173 // Run: pnpm build && node scripts/serve-static.mjs --port 4173
import { createServer } from "node:http"; import { createServer } from "node:http";
import { readFileSync, statSync } from "node:fs"; import { readFileSync, statSync } from "node:fs";
import { extname, join } from "node:path"; import { extname, join } from "node:path";
@@ -56,7 +56,7 @@ function serve(req, res) {
res.end(body); res.end(body);
return; return;
} catch { } catch {
/* not found — try next candidate */ /* not found - try next candidate */
} }
} }
res.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" }); res.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" });
+3 -5
View File
@@ -1,9 +1,7 @@
// Color authorship: every color value in app.css must be authored as hct() // Color authorship: every color value in app.css must be authored as hct()
// — literals AND derived forms (hct(from var(...) h c t) with channel math) — // (literals and derived forms), so the whole palette is computed through HCT
// so the whole palette is computed through HCT channels and output as sRGB by // and output as sRGB by postcss-hct. Exceptions: the seed tokens
// the postcss-hct plugin. Exceptions: the seed tokens --brand-main/--brand-alt // --brand-main/--brand-alt and color-mix(...) (the only sanctioned blend).
// (any format allowed) and color-mix(...) (the only sanctioned way to blend
// two tokens).
import { isColorValue } from "./helpers.mjs"; import { isColorValue } from "./helpers.mjs";
+2 -2
View File
@@ -41,14 +41,14 @@ export function collectUsedTokens() {
return used; return used;
} }
// A token is "colorful" when its value is a color literal — those must have a // A token is "colorful" when its value is a color literal; those must have a
// dark-mate. Non-color tokens (fonts, radii, durations) are exempt. // dark-mate. Non-color tokens (fonts, radii, durations) are exempt.
const COLOR_RE = const COLOR_RE =
/#[0-9a-fA-F]{3,8}\b|\b(?:rgb|rgba|hsl|hsla|hwb|lab|lch|oklch|oklab|hct|color-mix)\s*\(/i; /#[0-9a-fA-F]{3,8}\b|\b(?:rgb|rgba|hsl|hsla|hwb|lab|lch|oklch|oklab|hct|color-mix)\s*\(/i;
export const isColorValue = (value) => COLOR_RE.test(value); export const isColorValue = (value) => COLOR_RE.test(value);
// A token is "derived" when its value references var(...) — it adapts to the // A token is "derived" when its value references var(...); it adapts to the
// theme automatically, so it must NOT have a hardcoded dark twin. // theme automatically, so it must NOT have a hardcoded dark twin.
export const isDerived = (value) => value.includes("var("); export const isDerived = (value) => value.includes("var(");
+2 -5
View File
@@ -1,9 +1,6 @@
// Unresolved references: every var(--x) in app.css must point at a token the // Unresolved references: every var(--x) in app.css must point at a token the
// same file defines. A typo here silently drops the declaration, so unlike the // same file defines: a typo silently drops the declaration, so this is a
// unused-token dust it is a defect, not a style choice. // defect, not a style choice. `var(--x, fallback)` is legal without --x.
//
// `var(--x, fallback)` is legal without --x (CSS custom property fallback), so
// those references are exempt.
const VAR_REF_RE = /var\(\s*(--[\w-]+)\s*([,)])/g; const VAR_REF_RE = /var\(\s*(--[\w-]+)\s*([,)])/g;
+1 -1
View File
@@ -1,5 +1,5 @@
// Unused tokens: defined in app.css but never referenced via var() // Unused tokens: defined in app.css but never referenced via var()
// anywhere in src. A dead token is not the single source of truth — it's dust. // anywhere in src. A dead token is not the single source of truth; it's dust.
// Reported as a warning; it does not fail the run. The `used` set is injectable // Reported as a warning; it does not fail the run. The `used` set is injectable
// so tests can pass a fixture instead of scanning src. // so tests can pass a fixture instead of scanning src.
+1 -1
View File
@@ -7,7 +7,7 @@ export type PreviewGroup = {
}; };
/** /**
* Preview-каталог: все страницы, сгруппированные по категориям. * Preview catalog: all pages grouped by category.
*/ */
export const PREVIEW_GROUPS: PreviewGroup[] = CATEGORY_IDS.map((category) => ({ export const PREVIEW_GROUPS: PreviewGroup[] = CATEGORY_IDS.map((category) => ({
id: category, id: category,
+4 -8
View File
@@ -1,11 +1,7 @@
/** /**
* Категории инструментов для нового registry. * Tool categories: `CategoryId` type and catalog display order derive from
* * one `as const` object to avoid drift. Human-readable names live in the
* Единая точка правды: тип `CategoryId` и порядок отображения в каталоге * i18n dictionaries, section categories, key = CategoryId.
* выводятся из одного `as const`-объекта, чтобы не рассинхронизироваться.
*
* Человекочитаемые названия живут в словарях i18n: секция categories,
* ключ = CategoryId.
*/ */
export const CATEGORIES = { export const CATEGORIES = {
convert: "convert", convert: "convert",
@@ -20,5 +16,5 @@ export const CATEGORIES = {
export type CategoryId = (typeof CATEGORIES)[keyof typeof CATEGORIES]; export type CategoryId = (typeof CATEGORIES)[keyof typeof CATEGORIES];
/** Порядок категорий в каталоге (как задано в `CATEGORIES`). */ /** Category order in the catalog (as defined in `CATEGORIES`). */
export const CATEGORY_IDS = Object.keys(CATEGORIES) as CategoryId[]; export const CATEGORY_IDS = Object.keys(CATEGORIES) as CategoryId[];
+153
View File
@@ -0,0 +1,153 @@
import { formatStamp } from "./core/datefmt";
import type { OutputMime } from "./core/io";
import type { PixelImage } from "./core/types";
import { t } from "./i18n/t";
import {
CHAIN_VERSION,
parseChain,
serializeSteps,
type ChainStep,
} from "./pipeline.svelte";
export interface NamedChain {
id: string;
name: string;
updatedAt: number;
steps: ChainStep[];
}
const STORE_KEY = "chains:named";
function storage(): Storage | null {
return typeof localStorage === "undefined" ? null : localStorage;
}
export function autoChainName(date = new Date()): string {
return t("savedChains.autoName", {
stamp: formatStamp(date, "YYYY-MM-DD hh:mm"),
});
}
/**
* Lenient per entry: one broken chain must not kill the rest of the list.
* Steps reuse the same serialized shape and validation as parseChain.
*/
export function parseNamedChains(data: unknown): NamedChain[] {
if (
!data ||
typeof data !== "object" ||
(data as { version?: unknown }).version !== CHAIN_VERSION ||
!Array.isArray((data as { chains?: unknown }).chains)
) {
return [];
}
const out: NamedChain[] = [];
for (const raw of (data as { chains: unknown[] }).chains) {
if (!raw || typeof raw !== "object") continue;
const { id, name, updatedAt, steps } = raw as {
id?: unknown;
name?: unknown;
updatedAt?: unknown;
steps?: unknown;
};
if (typeof id !== "string" || typeof name !== "string") continue;
const parsed = parseChain({ version: CHAIN_VERSION, steps });
if (!parsed) continue;
out.push({
id,
name,
updatedAt:
typeof updatedAt === "number" && Number.isFinite(updatedAt)
? updatedAt
: 0,
steps: parsed,
});
}
return out;
}
export function serializeNamedChains(
chains: NamedChain[],
): Record<string, unknown> {
return {
version: CHAIN_VERSION,
chains: chains.map((c) => ({
id: c.id,
name: c.name,
updatedAt: c.updatedAt,
steps: serializeSteps(c.steps),
})),
};
}
function readAll(): NamedChain[] {
const raw = storage()?.getItem(STORE_KEY);
if (!raw) return [];
try {
return parseNamedChains(JSON.parse(raw));
} catch {
return [];
}
}
function writeAll(chains: NamedChain[]): void {
storage()?.setItem(STORE_KEY, JSON.stringify(serializeNamedChains(chains)));
}
/** Newest first. */
export function listChains(): NamedChain[] {
return readAll().sort((a, b) => b.updatedAt - a.updatedAt);
}
export function getChain(id: string): NamedChain | undefined {
return readAll().find((c) => c.id === id);
}
export function createNamedChain(name: string, steps: ChainStep[]): NamedChain {
const chain: NamedChain = {
id: crypto.randomUUID(),
name: name.trim() || autoChainName(),
updatedAt: Date.now(),
steps,
};
writeAll([...readAll(), chain]);
return chain;
}
export function updateChainSteps(id: string, steps: ChainStep[]): void {
writeAll(
readAll().map((c) =>
c.id === id ? { ...c, steps, updatedAt: Date.now() } : c,
),
);
}
export function renameChain(id: string, name: string): void {
const trimmed = name.trim();
if (!trimmed) return;
writeAll(readAll().map((c) => (c.id === id ? { ...c, name: trimmed } : c)));
}
export function deleteChain(id: string): void {
writeAll(readAll().filter((c) => c.id !== id));
}
/** State that cannot persist across the tool -> pipeline route swap. */
export interface ChainHandoff {
source: PixelImage | null;
textSource: string;
format: OutputMime;
limitKb: number | undefined;
}
const handoffs = new Map<string, ChainHandoff>();
export function stashHandoff(id: string, handoff: ChainHandoff): void {
handoffs.set(id, handoff);
}
export function takeHandoff(id: string): ChainHandoff | undefined {
const handoff = handoffs.get(id);
handoffs.delete(id);
return handoff;
}
+76
View File
@@ -0,0 +1,76 @@
import { describe, expect, it } from "vitest";
import {
autoChainName,
parseNamedChains,
serializeNamedChains,
} from "./chains.svelte";
import {
CHAIN_VERSION,
createChain,
isStructuralDefault,
} from "./pipeline.svelte";
import { getPageBySlug } from "./registry";
const cropPage = getPageBySlug("crop-png")!;
describe("named chains: parse/serialize round-trip", () => {
const chain = {
id: "c1",
name: "My chain",
updatedAt: 123,
steps: createChain(cropPage),
};
it("round-trips a valid chain", () => {
const data = serializeNamedChains([chain]);
const parsed = parseNamedChains(JSON.parse(JSON.stringify(data)));
expect(parsed).toHaveLength(1);
expect(parsed[0]).toMatchObject({ id: "c1", name: "My chain" });
expect(parsed[0].steps[0].id).toBe("crop");
});
it("drops a broken entry, keeps the rest", () => {
const good = JSON.parse(JSON.stringify(serializeNamedChains([chain]))) as {
chains: unknown[];
};
const data = {
version: CHAIN_VERSION,
chains: [
good.chains[0],
{ id: "bad", name: "Broken", steps: [{ id: "nope" }] },
{ nope: true },
],
};
const parsed = parseNamedChains(data);
expect(parsed.map((c) => c.id)).toEqual(["c1"]);
});
it.each([
["junk", "just a string"],
["no version", { chains: [] }],
["other version", { version: 99, chains: [] }],
])("invalid store -> empty list (%s)", (_name, data) => {
expect(parseNamedChains(data)).toEqual([]);
});
});
describe("isStructuralDefault", () => {
it("true for the untouched page chain", () => {
expect(isStructuralDefault(cropPage, createChain(cropPage))).toBe(true);
});
it("false when a step is added or the first tool is swapped", () => {
const grown = [...createChain(cropPage), ...createChain(cropPage)];
expect(isStructuralDefault(cropPage, grown)).toBe(false);
const swapped = createChain(cropPage);
swapped[0] = { ...swapped[0], id: "resize" };
expect(isStructuralDefault(cropPage, swapped)).toBe(false);
});
});
describe("autoChainName", () => {
it("embeds the formatted stamp", () => {
const name = autoChainName(new Date(2026, 9, 8, 14, 35));
expect(name).toContain("2026-10-08 14:35");
});
});
+1 -1
View File
@@ -4,7 +4,7 @@
interface Props { interface Props {
title: string; title: string;
description: string; description: string;
/** Путь страницы от корня роутера ("/", "/list-tools", "/tools/…"). */ /** Page path from the router root ("/", "/list-tools", "/tools/..."). */
path: string; path: string;
} }
let { title, description, path }: Props = $props(); let { title, description, path }: Props = $props();
@@ -13,7 +13,7 @@
label: string; label: string;
value: unknown; value: unknown;
spec: FieldSpec; spec: FieldSpec;
/** Размеры входа шага: потолок осей для maxFromSource-полей. */ /** Step input dimensions: axis cap for maxFromSource fields. */
sourceDims?: Dimension; sourceDims?: Dimension;
onchange?: (value: Dimension, axis?: "width" | "height" | "both") => void; onchange?: (value: Dimension, axis?: "width" | "height" | "both") => void;
} }
@@ -51,7 +51,7 @@
} }
function applyPreset(n: number) { function applyPreset(n: number) {
// Пресет задаёт обе оси явно — lockAspect его не пересчитывает. // Preset sets both axes explicitly; lockAspect does not recompute it.
onchange?.({ width: n, height: n }, "both"); onchange?.({ width: n, height: n }, "both");
} }
</script> </script>
@@ -6,6 +6,7 @@
<nav class="nav"> <nav class="nav">
<a href={resolve("/")}>{t("header.home")}</a> <a href={resolve("/")}>{t("header.home")}</a>
<a href={resolve("/list-tools")}>{t("header.catalog")}</a> <a href={resolve("/list-tools")}>{t("header.catalog")}</a>
<a href={resolve("/chains")}>{t("header.chains")}</a>
<a href={resolve("/kit")}>{t("header.uiKit")}</a> <a href={resolve("/kit")}>{t("header.uiKit")}</a>
</nav> </nav>
@@ -15,6 +15,7 @@
dims?: string; dims?: string;
note?: string; note?: string;
noteTone?: "warning" | "info"; noteTone?: "warning" | "info";
actions?: Snippet;
} }
let { let {
label, label,
@@ -26,6 +27,7 @@
dims = undefined, dims = undefined,
note = undefined, note = undefined,
noteTone = "warning", noteTone = "warning",
actions = undefined,
}: Props = $props(); }: Props = $props();
</script> </script>
@@ -44,6 +46,9 @@
{/if} {/if}
</span> </span>
<span class="tile-note"> <span class="tile-note">
{#if actions}
{@render actions()}
{/if}
{#if note} {#if note}
<Badge tone={noteTone} label={note} /> <Badge tone={noteTone} label={note} />
{/if} {/if}
@@ -14,11 +14,13 @@
running: boolean; running: boolean;
format?: OutputMime; format?: OutputMime;
quality?: number; quality?: number;
limitKb?: number;
alphaLoss?: boolean; alphaLoss?: boolean;
onupload: (file: File) => void; onupload: (file: File) => void;
ondownload: () => void; ondownload: () => void;
onformat?: (mime: OutputMime) => void; onformat?: (mime: OutputMime) => void;
onquality?: (value: number) => void; onquality?: (value: number) => void;
onlimit?: (kb: number | undefined) => void;
} }
let { let {
inputMode, inputMode,
@@ -27,11 +29,13 @@
running, running,
format = "image/png", format = "image/png",
quality = undefined, quality = undefined,
limitKb = undefined,
alphaLoss = false, alphaLoss = false,
onupload, onupload,
ondownload, ondownload,
onformat, onformat,
onquality, onquality,
onlimit,
}: Props = $props(); }: Props = $props();
</script> </script>
@@ -43,10 +47,12 @@
<SchemaDownload <SchemaDownload
{format} {format}
{quality} {quality}
{limitKb}
{running} {running}
{ondownload} {ondownload}
onformat={(v) => onformat?.(v)} onformat={(v) => onformat?.(v)}
onquality={(v) => onquality?.(v)} onquality={(v) => onquality?.(v)}
onlimit={(v) => onlimit?.(v)}
/> />
{:else if canDownload && resultKind === "files"} {:else if canDownload && resultKind === "files"}
<DownloadButton <DownloadButton
@@ -13,13 +13,23 @@
interface Props { interface Props {
format: OutputMime; format: OutputMime;
quality?: number; quality?: number;
limitKb?: number;
running: boolean; running: boolean;
ondownload: () => void; ondownload: () => void;
onformat: (mime: OutputMime) => void; onformat: (mime: OutputMime) => void;
onquality: (value: number) => void; onquality: (value: number) => void;
onlimit: (kb: number | undefined) => void;
} }
let { format, quality, running, ondownload, onformat, onquality }: Props = let {
$props(); format,
quality,
limitKb,
running,
ondownload,
onformat,
onquality,
onlimit,
}: Props = $props();
const current = $derived(outputFormatByMime(format)); const current = $derived(outputFormatByMime(format));
const qualitySetting = $derived(current.settings?.quality); const qualitySetting = $derived(current.settings?.quality);
@@ -39,11 +49,16 @@
let root = $state<HTMLDivElement | null>(null); let root = $state<HTMLDivElement | null>(null);
function choose(mime: OutputMime) { function choose(mime: OutputMime) {
// Меню — ещё и панель настроек формата: не закрываем при выборе, // Menu doubles as a format-settings panel: keep it open on select
// чтобы можно было сразу настроить качество. // so quality can be tuned right away.
onformat(mime); onformat(mime);
} }
function handleLimit(e: Event) {
const raw = (e.target as HTMLInputElement).valueAsNumber;
onlimit(Number.isFinite(raw) && raw >= 1 ? Math.round(raw) : undefined);
}
$effect(() => { $effect(() => {
if (!open) return; if (!open) return;
function onPointerDown(e: PointerEvent) { function onPointerDown(e: PointerEvent) {
@@ -110,6 +125,24 @@
/> />
</div> </div>
{/if} {/if}
<div class="limit">
<label class="limit-field">
<span>{t("actions.sizeLimitKb")}</span>
<input
type="number"
min="1"
step="1"
placeholder="∞"
value={limitKb ?? ""}
oninput={handleLimit}
/>
</label>
{#if limitKb !== undefined && format === "image/bmp"}
<p class="limit-warn" role="status">
{t("actions.sizeLimitBmp")}
</p>
{/if}
</div>
</div> </div>
{/if} {/if}
</div> </div>
@@ -183,4 +216,37 @@
padding-top: var(--space-m); padding-top: var(--space-m);
border-top: var(--size-border) solid var(--color-border); border-top: var(--size-border) solid var(--color-border);
} }
.limit {
padding-top: var(--space-m);
border-top: var(--size-border) solid var(--color-border);
}
.limit-field {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-m);
color: var(--color-text-muted);
font: var(--font-size-s) var(--font-mono);
}
.limit-field input {
width: calc(var(--space-xxxl) * 3);
padding: var(--space-m) var(--space-l);
background: var(--color-background);
border: var(--size-border) solid var(--color-border);
border-radius: var(--radius-s);
color: var(--color-text);
font: var(--font-size-m) var(--font-mono);
text-align: center;
appearance: textfield;
}
.limit-field input::-webkit-outer-spin-button,
.limit-field input::-webkit-inner-spin-button {
appearance: none;
margin: 0;
}
.limit-warn {
margin: var(--space-m) 0 0;
color: var(--color-warning);
font: var(--font-size-s) var(--font-mono);
}
</style> </style>
@@ -58,7 +58,7 @@
schema: ToolSchema<Record<string, unknown>>; schema: ToolSchema<Record<string, unknown>>;
values: Record<string, unknown>; values: Record<string, unknown>;
toolId: string; toolId: string;
/** Размеры входа шага: для maxFromSource-полей (crop и т.п.). */ /** Step input dimensions: for maxFromSource fields (crop, etc.). */
sourceDims?: Dimension; sourceDims?: Dimension;
onchange: ( onchange: (
id: string, id: string,
@@ -5,6 +5,7 @@
import SchemaSourceTile from "./SchemaSourceTile.svelte"; import SchemaSourceTile from "./SchemaSourceTile.svelte";
import PreviewTile from "./PreviewTile.svelte"; import PreviewTile from "./PreviewTile.svelte";
import Toggle from "$lib/components/ui/Toggle.svelte"; import Toggle from "$lib/components/ui/Toggle.svelte";
import ChipButton from "$lib/components/ui/ChipButton.svelte";
import { toDataUrl } from "$lib/core/io"; import { toDataUrl } from "$lib/core/io";
import type { PixelImage } from "$lib/core/types"; import type { PixelImage } from "$lib/core/types";
import type { OutputMime } from "$lib/core/io"; import type { OutputMime } from "$lib/core/io";
@@ -27,13 +28,21 @@
textSource?: string; textSource?: string;
textResult?: string | null; textResult?: string | null;
textVars?: Record<string, string | number>; textVars?: Record<string, string | number>;
mask?: PixelImage | null;
maskOn?: boolean;
ontogglemask?: () => void;
running?: boolean; running?: boolean;
error?: string; error?: string;
stepResults?: (PixelImage | null)[]; stepResults?: (PixelImage | null)[];
stepMaskable?: boolean[];
stepMaskOn?: boolean[];
stepMasks?: (PixelImage | null)[];
ontogglestepmask?: (i: number) => void;
aligned?: boolean; aligned?: boolean;
ontogglealign?: () => void; ontogglealign?: () => void;
format?: OutputMime; format?: OutputMime;
quality?: number; quality?: number;
limitKb?: number;
alphaLoss?: boolean; alphaLoss?: boolean;
onupload: (file: File) => void; onupload: (file: File) => void;
ontextsource?: (text: string) => void; ontextsource?: (text: string) => void;
@@ -43,6 +52,7 @@
ondownload: () => void; ondownload: () => void;
onformat?: (mime: OutputMime) => void; onformat?: (mime: OutputMime) => void;
onquality?: (value: number) => void; onquality?: (value: number) => void;
onlimit?: (kb: number | undefined) => void;
} }
let { let {
inputMode, inputMode,
@@ -55,13 +65,21 @@
textSource = "", textSource = "",
textResult = null, textResult = null,
textVars = undefined, textVars = undefined,
mask = null,
maskOn = false,
ontogglemask,
running = false, running = false,
error = "", error = "",
stepResults = [], stepResults = [],
stepMaskable = [],
stepMaskOn = [],
stepMasks = [],
ontogglestepmask,
aligned = false, aligned = false,
ontogglealign, ontogglealign,
format = "image/png", format = "image/png",
quality = undefined, quality = undefined,
limitKb = undefined,
alphaLoss = false, alphaLoss = false,
onupload, onupload,
ontextsource, ontextsource,
@@ -71,6 +89,7 @@
ondownload, ondownload,
onformat, onformat,
onquality, onquality,
onlimit,
}: Props = $props(); }: Props = $props();
const hasResult = $derived( const hasResult = $derived(
@@ -97,11 +116,13 @@
{running} {running}
{format} {format}
{quality} {quality}
{limitKb}
{alphaLoss} {alphaLoss}
{onupload} {onupload}
{ondownload} {ondownload}
{onformat} {onformat}
{onquality} {onquality}
{onlimit}
/> />
</div> </div>
@@ -126,7 +147,21 @@
viewMode="image" viewMode="image"
dims={`${img.width} × ${img.height}`} dims={`${img.width} × ${img.height}`}
> >
<img src={toDataUrl(img)} alt="" /> {#snippet actions()}
{#if stepMaskable[i]}
<ChipButton
label={t("resultCard.maskToggle")}
active={stepMaskOn[i] ?? false}
onclick={() => ontogglestepmask?.(i)}
/>
{/if}
{/snippet}
<img
src={toDataUrl(
stepMaskOn[i] && stepMasks[i] ? (stepMasks[i] as PixelImage) : img,
)}
alt=""
/>
</PreviewTile> </PreviewTile>
{/if} {/if}
{/each} {/each}
@@ -139,6 +174,9 @@
{textVars} {textVars}
{toolId} {toolId}
{running} {running}
{mask}
{maskOn}
{ontogglemask}
oncopy={oncopytext} oncopy={oncopytext}
{ondownloadtxt} {ondownloadtxt}
/> />
@@ -4,6 +4,7 @@
import { t } from "$lib/i18n/t"; import { t } from "$lib/i18n/t";
import type { FileResult, ResultKind, ResultNote } from "$lib/registry"; import type { FileResult, ResultKind, ResultNote } from "$lib/registry";
import { Check } from "@lucide/svelte"; import { Check } from "@lucide/svelte";
import ChipButton from "$lib/components/ui/ChipButton.svelte";
import SchemaTextResult from "./SchemaTextResult.svelte"; import SchemaTextResult from "./SchemaTextResult.svelte";
import PreviewTile from "./PreviewTile.svelte"; import PreviewTile from "./PreviewTile.svelte";
@@ -16,6 +17,9 @@
textVars?: Record<string, string | number>; textVars?: Record<string, string | number>;
toolId?: string; toolId?: string;
running?: boolean; running?: boolean;
mask?: PixelImage | null;
maskOn?: boolean;
ontogglemask?: () => void;
oncopy?: () => void; oncopy?: () => void;
ondownloadtxt?: () => void; ondownloadtxt?: () => void;
} }
@@ -28,11 +32,15 @@
textVars = undefined, textVars = undefined,
toolId = "", toolId = "",
running = false, running = false,
mask = null,
maskOn = false,
ontogglemask,
oncopy, oncopy,
ondownloadtxt, ondownloadtxt,
}: Props = $props(); }: Props = $props();
const resultUrl = $derived(result ? toDataUrl(result) : null); const shown = $derived(maskOn && mask ? mask : result);
const resultUrl = $derived(shown ? toDataUrl(shown) : null);
const resultDims = $derived( const resultDims = $derived(
resultKind === "image" && result resultKind === "image" && result
? `${result.width} × ${result.height}` ? `${result.width} × ${result.height}`
@@ -50,7 +58,7 @@
let parts = $derived( let parts = $derived(
resultKind === "files" && fileResult ? fileResult.files.length : undefined, resultKind === "files" && fileResult ? fileResult.files.length : undefined,
); );
// Размеры одинаковы у всех частей (канвас дополняется) — показываем один раз. // All parts share the same size (canvas is padded); show it once.
let partsDims = $derived( let partsDims = $derived(
resultKind === "files" && fileResult && fileResult.files.length > 0 resultKind === "files" && fileResult && fileResult.files.length > 0
? `${fileResult.files[0].image.width} × ${fileResult.files[0].image.height}` ? `${fileResult.files[0].image.width} × ${fileResult.files[0].image.height}`
@@ -68,6 +76,15 @@
note={resultNote ? t(resultNote.key) : undefined} note={resultNote ? t(resultNote.key) : undefined}
noteTone={resultNote?.tone ?? "warning"} noteTone={resultNote?.tone ?? "warning"}
> >
{#snippet actions()}
{#if ontogglemask}
<ChipButton
label={t("resultCard.maskToggle")}
active={maskOn}
onclick={ontogglemask}
/>
{/if}
{/snippet}
<div <div
class="canvas" class="canvas"
class:checker={resultKind === "image"} class:checker={resultKind === "image"}
@@ -33,7 +33,7 @@
let dragging = $state(false); let dragging = $state(false);
let input = $state<HTMLInputElement | null>(null); let input = $state<HTMLInputElement | null>(null);
// Подсказка замены: показываем на ховер и гасим через 10с до нового захода. // Replace hint: show on hover, fade after 10s until the next hover.
let hintVisible = $state(false); let hintVisible = $state(false);
let hintTimer: ReturnType<typeof setTimeout> | null = null; let hintTimer: ReturnType<typeof setTimeout> | null = null;
@@ -1,5 +1,10 @@
<script lang="ts"> <script lang="ts">
import { goto } from "$app/navigation";
import { resolve } from "$app/paths";
import StepCard from "$lib/components/display/StepCard.svelte"; import StepCard from "$lib/components/display/StepCard.svelte";
import Button from "$lib/components/ui/Button.svelte";
import ChipButton from "$lib/components/ui/ChipButton.svelte";
import { ButtonVariantDefine } from "$lib/components/ui/define";
import Toggle from "$lib/components/ui/Toggle.svelte"; import Toggle from "$lib/components/ui/Toggle.svelte";
import { isFav, toggleFav } from "$lib/favorites.svelte"; import { isFav, toggleFav } from "$lib/favorites.svelte";
import { Star } from "@lucide/svelte"; import { Star } from "@lucide/svelte";
@@ -10,6 +15,7 @@
decodeFile, decodeFile,
downloadBlob, downloadBlob,
encode, encode,
fitWithinBytes,
outputFormatByMime, outputFormatByMime,
toDataUrl, toDataUrl,
type OutputMime, type OutputMime,
@@ -19,22 +25,29 @@
import { import {
pageDescription, pageDescription,
pageTitle, pageTitle,
chainStepTitle,
verdictText, verdictText,
} from "$lib/i18n/schema-tool-strings"; } from "$lib/i18n/schema-tool-strings";
import { t } from "$lib/i18n/t"; import { t } from "$lib/i18n/t";
import {
autoChainName,
createNamedChain,
getChain,
stashHandoff,
takeHandoff,
updateChainSteps,
} from "$lib/chains.svelte";
import { import {
createChain, createChain,
createStep, createStep,
insertStep, insertStep,
loadChain, isStructuralDefault,
moveStep, moveStep,
removeStep, removeStep,
saveChain,
type ChainStep, type ChainStep,
} from "$lib/pipeline.svelte"; } from "$lib/pipeline.svelte";
import { import {
getTool, getTool,
PAGES,
type FileResult, type FileResult,
type Page, type Page,
type Tool, type Tool,
@@ -63,11 +76,13 @@
interface Props { interface Props {
page: Page; page: Page;
tool: Tool; tool: Tool;
/** Named-chain mode: the chain id and display name; page is virtual. */
chainId?: string;
} }
type DisplayError = type DisplayError =
| { kind: "i18n"; key: string; vars?: ErrorVars } | { kind: "i18n"; key: string; vars?: ErrorVars }
| { kind: "plain"; text: string }; | { kind: "plain"; text: string };
let { page, tool }: Props = $props(); let { page, tool, chainId = undefined }: Props = $props();
const schema = $derived( const schema = $derived(
tool.schema as ToolSchema<Record<string, unknown>> | undefined, tool.schema as ToolSchema<Record<string, unknown>> | undefined,
@@ -76,10 +91,10 @@
const inputMode = $derived(tool.input); const inputMode = $derived(tool.input);
let steps = $state<ChainStep[]>([]); let steps = $state<ChainStep[]>([]);
// Поля, изменённые пользователем (по шагам): при загрузке нового файла им не // Fields edited by the user (per step): on new file load they must not be
// даём перезаписаться source-дефолтами. Reset шага снимает пометку. // overwritten by source defaults. Step reset clears the mark.
const touchedByStep = new SvelteMap<string, SvelteSet<string>>(); const touchedByStep = new SvelteMap<string, SvelteSet<string>>();
// Последняя правленая ось dimension-поля — ведущая при lockAspect. // Last edited axis of a dimension field; leads under lockAspect.
const lastAxis: Record<string, "width" | "height"> = {}; const lastAxis: Record<string, "width" | "height"> = {};
let stepDims = $state<(Dimension | undefined)[]>([]); let stepDims = $state<(Dimension | undefined)[]>([]);
let stepResults = $state<(PixelImage | null)[]>([]); let stepResults = $state<(PixelImage | null)[]>([]);
@@ -96,8 +111,14 @@
let displayError = $state<DisplayError | null>(null); let displayError = $state<DisplayError | null>(null);
let initedFor = $state(""); let initedFor = $state("");
let format = $state<OutputMime>("image/png"); let format = $state<OutputMime>("image/png");
// Качество lossy-форматов для инструментов без quality-параметра в схеме. // Lossy-format quality for tools without a quality param in the schema.
let formatQuality = $state<Record<string, number>>({}); let formatQuality = $state<Record<string, number>>({});
// Optional download size limit (KB); undefined = no limit.
let limitKb = $state<number | undefined>(undefined);
// Mask preview per step (keyed by step.key): the toggle lives on every
// step result tile, not just the final one.
const maskOnKeys = new SvelteSet<string>();
let maskResults = $state<Record<string, PixelImage | null>>({});
let dragFrom = $state<number | null>(null); let dragFrom = $state<number | null>(null);
const lastTool = $derived( const lastTool = $derived(
@@ -105,7 +126,7 @@
tool, tool,
); );
const resultKind = $derived(lastTool.result ?? "image"); const resultKind = $derived(lastTool.result ?? "image");
// Цепочку можно продолжить, только если последний шаг отдаёт картинку. // Chain can be extended only if the last step outputs an image.
const canExtend = $derived((lastTool.result ?? "image") === "image"); const canExtend = $derived((lastTool.result ?? "image") === "image");
const alignedMode = $derived( const alignedMode = $derived(
aligned && resultKind === "image" && steps.length > 1, aligned && resultKind === "image" && steps.length > 1,
@@ -117,8 +138,8 @@
!outputFormatByMime(format).supportsAlpha && !outputFormatByMime(format).supportsAlpha &&
hasTransparency(result), hasTransparency(result),
); );
// Если в схеме последнего шага есть quality-параметр (convert-инструменты), // If the last step's schema has a quality param (convert tools), it is the
// он — единый источник качества; dropdown редактирует его же. // single source of quality; the dropdown edits the same value.
const qualityParamId = $derived(lastTool.output?.qualityParamId); const qualityParamId = $derived(lastTool.output?.qualityParamId);
const lastParams = $derived(steps.at(-1)?.params); const lastParams = $derived(steps.at(-1)?.params);
const resultNote = $derived( const resultNote = $derived(
@@ -149,6 +170,10 @@
: current.text; : current.text;
}); });
const stepMaskable = $derived(steps.map((s) => !!getTool(s.id)?.runMask));
const stepMaskOn = $derived(steps.map((s) => maskOnKeys.has(s.key)));
const stepMasks = $derived(steps.map((s) => maskResults[s.key] ?? null));
const debouncedRun = debounce(() => run(), 200); const debouncedRun = debounce(() => run(), 200);
function touchedFor(key: string): SvelteSet<string> { function touchedFor(key: string): SvelteSet<string> {
@@ -165,21 +190,36 @@
} }
function stepTitle(toolId: string): string { function stepTitle(toolId: string): string {
const owner = PAGES.find((p) => p.steps[0].id === toolId); return chainStepTitle(toolId);
return owner ? pageTitle(owner) : toolId;
} }
function init() { function init() {
if (!schema || page.slug === initedFor) return; if (!schema) return;
initedFor = page.slug; const host = chainId ? `chain:${chainId}` : page.slug;
steps = loadChain(page.slug) ?? createChain(page); if (host === initedFor) return;
initedFor = host;
if (chainId) {
const handoff = takeHandoff(chainId);
steps = getChain(chainId)?.steps ?? createChain(page);
source = handoff?.source ?? null;
textSource = handoff?.textSource ?? "";
if (handoff?.format) format = handoff.format;
else format = lastTool.output?.mime ?? "image/png";
limitKb = handoff?.limitKb;
} else {
steps = createChain(page);
format = lastTool.output?.mime ?? "image/png"; format = lastTool.output?.mime ?? "image/png";
limitKb = undefined;
source = null; source = null;
textSource = "";
}
result = null; result = null;
fileResult = null; fileResult = null;
textResult = null; textResult = null;
verdictVars = undefined; verdictVars = undefined;
displayError = null; displayError = null;
maskOnKeys.clear();
maskResults = {};
} }
function setStepValue( function setStepValue(
@@ -196,8 +236,8 @@
const next: Record<string, unknown> = { ...step.params, [id]: value }; const next: Record<string, unknown> = { ...step.params, [id]: value };
const stepSchema = toolSchemaOf(step); const stepSchema = toolSchemaOf(step);
const spec = stepSchema?.fields[id]?.spec; const spec = stepSchema?.fields[id]?.spec;
// Аспект считается от входа шага: для первого — исходник, для остальных — // Aspect derives from the step input: source for the first step,
// результат предыдущего шага (размеры известны после прогона). // previous step's result for the rest (dims known after the run).
const dims = index === 0 ? (source ?? undefined) : stepDims[index - 1]; const dims = index === 0 ? (source ?? undefined) : stepDims[index - 1];
if (dims && stepSchema && spec) { if (dims && stepSchema && spec) {
const aspect = dims.width / dims.height; const aspect = dims.width / dims.height;
@@ -213,7 +253,7 @@
aspect, aspect,
); );
} else if (spec.kind === "checkbox" && value === true) { } else if (spec.kind === "checkbox" && value === true) {
// Включили lockAspect — сразу подгоняем привязанные поля под аспект. // lockAspect just enabled: snap bound fields to the aspect at once.
for (const [fid, f] of Object.entries(stepSchema.fields)) { for (const [fid, f] of Object.entries(stepSchema.fields)) {
const fs = f.spec; const fs = f.spec;
if (fs.kind === "dimension" && fs.lockAspectWith === id) { if (fs.kind === "dimension" && fs.lockAspectWith === id) {
@@ -261,8 +301,8 @@
steps = steps.with(index, { ...step, collapsed: !step.collapsed }); steps = steps.with(index, { ...step, collapsed: !step.collapsed });
} }
// Замена инструмента на месте: ключ и свёрнутость шага сохраняются, // In-place tool swap: step key and collapsed state are kept, params become
// параметры — дефолты нового инструмента, вход пересчитывается при прогоне. // the new tool's defaults, input is recomputed on run.
function replaceStepTool(index: number, toolId: string) { function replaceStepTool(index: number, toolId: string) {
const step = steps[index]; const step = steps[index];
const next = createStep(toolId); const next = createStep(toolId);
@@ -397,7 +437,9 @@
const out = outputFormatByMime(format); const out = outputFormatByMime(format);
const quality = const quality =
currentQuality !== undefined ? currentQuality / 100 : undefined; currentQuality !== undefined ? currentQuality / 100 : undefined;
const blob = await encode(result, out.mime, quality); const blob = limitKb
? await fitWithinBytes(result, out.mime, limitKb * 1024, quality)
: await encode(result, out.mime, quality);
downloadBlob(blob, `${page.slug}.${out.ext}`); downloadBlob(blob, `${page.slug}.${out.ext}`);
} catch (e) { } catch (e) {
displayError = toDisplayError(e); displayError = toDisplayError(e);
@@ -433,11 +475,28 @@
init(); init();
}); });
// Цепочка персистится по slug страницы; сохраняем после инициализации. // Named-chain mode autosaves into the chains store; a plain tool page
// persists nothing -- it becomes a named chain on structural change below.
$effect(() => { $effect(() => {
if (!initedFor) return; if (!initedFor || !chainId) return;
void steps; void steps;
saveChain(initedFor, steps); updateChainSteps(chainId, steps);
});
// A tool page turns into a pipeline on the first structural change (step
// added, first tool swapped): create the named chain and swap routes
// seamlessly -- steps persist via the store, the source via the handoff.
$effect(() => {
if (!initedFor || chainId) return;
void steps;
if (isStructuralDefault(page, steps)) return;
const chain = createNamedChain(autoChainName(), steps);
stashHandoff(chain.id, { source, textSource, format, limitKb });
goto(resolve(`/pipeline?id=${chain.id}`), {
replaceState: true,
keepFocus: true,
noScroll: true,
});
}); });
onMount(() => { onMount(() => {
@@ -469,6 +528,41 @@
return () => debouncedRun.cancel(); return () => debouncedRun.cancel();
}); });
// Mask preview: recomputed on the main thread per toggled step from that
// step's input; a mask never enters the chain itself.
$effect(() => {
void steps;
void stepResults;
void source;
const next: Record<string, PixelImage | null> = {};
steps.forEach((step, i) => {
if (!maskOnKeys.has(step.key)) return;
const stepTool = getTool(step.id);
if (!stepTool?.runMask) return;
const input = i === 0 ? source : stepResults[i - 1];
if (!input) return;
try {
next[step.key] = stepTool.runMask({
params: step.params,
source: input,
});
} catch {
next[step.key] = null;
}
});
maskResults = next;
});
function toggleStepMask(key: string) {
if (maskOnKeys.has(key)) maskOnKeys.delete(key);
else maskOnKeys.add(key);
}
function toggleStepMaskByIndex(i: number) {
const step = steps[i];
if (step) toggleStepMask(step.key);
}
function toDisplayError(e: unknown): DisplayError { function toDisplayError(e: unknown): DisplayError {
if (e instanceof ToolError) { if (e instanceof ToolError) {
return { kind: "i18n", key: e.key, vars: e.vars }; return { kind: "i18n", key: e.key, vars: e.vars };
@@ -476,6 +570,18 @@
if (e instanceof Error) return { kind: "plain", text: e.message }; if (e instanceof Error) return { kind: "plain", text: e.message };
return { kind: "plain", text: String(e) }; return { kind: "plain", text: String(e) };
} }
// Explicit save for a structurally default chain (single tuned step): the
// auto-create effect only fires on structural change.
function saveAsChain() {
const chain = createNamedChain(autoChainName(), steps);
stashHandoff(chain.id, { source, textSource, format, limitKb });
goto(resolve(`/pipeline?id=${chain.id}`), {
replaceState: true,
keepFocus: true,
noScroll: true,
});
}
</script> </script>
{#if schema} {#if schema}
@@ -484,6 +590,7 @@
<div class="title-block"> <div class="title-block">
<h1> <h1>
{pageTitle(page)} {pageTitle(page)}
{#if !chainId}
<button <button
type="button" type="button"
class="tool-fav" class="tool-fav"
@@ -492,11 +599,25 @@
aria-pressed={isFav(page.slug)} aria-pressed={isFav(page.slug)}
onclick={() => toggleFav(page.slug)} onclick={() => toggleFav(page.slug)}
> >
<Star size={18} fill={isFav(page.slug) ? "currentColor" : "none"} /> <Star
size={18}
fill={isFav(page.slug) ? "currentColor" : "none"}
/>
</button> </button>
{/if}
</h1> </h1>
{#if pageDescription(page)}
<p class="lede">{pageDescription(page)}</p> <p class="lede">{pageDescription(page)}</p>
{/if}
</div> </div>
{#if !chainId}
<Button
label={t("savedChains.saveAs")}
variant={ButtonVariantDefine.OUTLINE}
size="s"
onclick={saveAsChain}
/>
{/if}
</header> </header>
{#snippet stepCard(step: ChainStep, i: number)} {#snippet stepCard(step: ChainStep, i: number)}
@@ -539,6 +660,16 @@
</StepCard> </StepCard>
{/snippet} {/snippet}
{#snippet maskChip(i: number)}
{#if stepMaskable[i]}
<ChipButton
label={t("resultCard.maskToggle")}
active={stepMaskOn[i]}
onclick={() => toggleStepMask(steps[i].key)}
/>
{/if}
{/snippet}
{#if !alignedMode} {#if !alignedMode}
<div class="workspace"> <div class="workspace">
<section class="settings"> <section class="settings">
@@ -569,12 +700,23 @@
error={errorText} error={errorText}
{format} {format}
quality={currentQuality} quality={currentQuality}
{limitKb}
{alphaLoss} {alphaLoss}
{stepResults} {stepResults}
{aligned} {aligned}
{stepMaskable}
{stepMaskOn}
{stepMasks}
ontogglestepmask={toggleStepMaskByIndex}
mask={stepMasks.at(-1) ?? null}
maskOn={stepMaskOn.at(-1) ?? false}
ontogglemask={stepMaskable.at(-1)
? () => toggleStepMask(steps[steps.length - 1].key)
: undefined}
ontogglealign={() => (aligned = !aligned)} ontogglealign={() => (aligned = !aligned)}
onformat={(v) => (format = v)} onformat={(v) => (format = v)}
onquality={setFormatQuality} onquality={setFormatQuality}
onlimit={(v) => (limitKb = v)}
onupload={handleFile} onupload={handleFile}
ontextsource={(textValue) => { ontextsource={(textValue) => {
textSource = textValue; textSource = textValue;
@@ -603,11 +745,13 @@
{running} {running}
{format} {format}
quality={currentQuality} quality={currentQuality}
{limitKb}
{alphaLoss} {alphaLoss}
onupload={handleFile} onupload={handleFile}
ondownload={download} ondownload={download}
onformat={(v) => (format = v)} onformat={(v) => (format = v)}
onquality={setFormatQuality} onquality={setFormatQuality}
onlimit={(v) => (limitKb = v)}
/> />
</div> </div>
</div> </div>
@@ -667,6 +811,11 @@
textVars={verdictVars} textVars={verdictVars}
toolId={lastTool.id} toolId={lastTool.id}
{running} {running}
mask={stepMasks[i] ?? null}
maskOn={stepMaskOn[i] ?? false}
ontogglemask={stepMaskable[i]
? () => toggleStepMask(steps[i].key)
: undefined}
oncopy={copyText} oncopy={copyText}
ondownloadtxt={downloadText} ondownloadtxt={downloadText}
/> />
@@ -676,8 +825,16 @@
viewMode="image" viewMode="image"
dims={out ? `${out.width} × ${out.height}` : undefined} dims={out ? `${out.width} × ${out.height}` : undefined}
> >
{#snippet actions()}
{@render maskChip(i)}
{/snippet}
{#if out} {#if out}
<img src={toDataUrl(out)} alt="" /> <img
src={toDataUrl(
stepMaskOn[i] && stepMasks[i] ? stepMasks[i]! : out,
)}
alt=""
/>
{:else} {:else}
<span class="empty">{t("resultCard.noResult")}</span> <span class="empty">{t("resultCard.noResult")}</span>
{/if} {/if}
@@ -18,12 +18,12 @@ function input(over: Partial<SchemaPreviewModelInput> = {}) {
} }
describe("hasPreviewResult", () => { describe("hasPreviewResult", () => {
it("image: результат только при наличии картинки", () => { it("image: result only when image present", () => {
expect(hasPreviewResult(input({ result: image }))).toBe(true); expect(hasPreviewResult(input({ result: image }))).toBe(true);
expect(hasPreviewResult(input())).toBe(false); expect(hasPreviewResult(input())).toBe(false);
}); });
it("files: результат только при непустом наборе", () => { it("files: result only for non-empty set", () => {
const fileResult = { files: [{ name: "part.png", image }] }; const fileResult = { files: [{ name: "part.png", image }] };
expect(hasPreviewResult(input({ resultKind: "files", fileResult }))).toBe( expect(hasPreviewResult(input({ resultKind: "files", fileResult }))).toBe(
true, true,
@@ -36,7 +36,7 @@ describe("hasPreviewResult", () => {
}); });
it.each(["text", "verdict"] as const)( it.each(["text", "verdict"] as const)(
"%s: результат по наличию текста", "%s: result depends on text presence",
(resultKind) => { (resultKind) => {
expect(hasPreviewResult(input({ resultKind, textResult: "done" }))).toBe( expect(hasPreviewResult(input({ resultKind, textResult: "done" }))).toBe(
true, true,
@@ -9,7 +9,7 @@ export type SchemaPreviewModelInput = {
textResult: string | null; textResult: string | null;
}; };
/** Можно ли скачать результат: он есть и он не пустой. */ /** Whether the result is downloadable: it exists and is non-empty. */
export function hasPreviewResult(input: SchemaPreviewModelInput): boolean { export function hasPreviewResult(input: SchemaPreviewModelInput): boolean {
const { resultKind, result, fileResult, textResult } = input; const { resultKind, result, fileResult, textResult } = input;
if (resultKind === "image") return Boolean(result); if (resultKind === "image") return Boolean(result);
+10 -10
View File
@@ -9,7 +9,7 @@ import {
import { makeImage } from "./test-helpers"; import { makeImage } from "./test-helpers";
describe("sampleBilinear", () => { describe("sampleBilinear", () => {
it("целые координаты возвращают точный пиксель", () => { it("integer coordinates return exact pixel", () => {
const img = makeImage(2, 1, [ const img = makeImage(2, 1, [
[255, 0, 0, 255], [255, 0, 0, 255],
[0, 0, 255, 255], [0, 0, 255, 255],
@@ -18,7 +18,7 @@ describe("sampleBilinear", () => {
expect(sampleBilinear(img, 1, 0)).toEqual([0, 0, 255, 255]); expect(sampleBilinear(img, 1, 0)).toEqual([0, 0, 255, 255]);
}); });
it("дробная координата интерполирует", () => { it("fractional coordinate interpolates", () => {
const img = makeImage(2, 1, [ const img = makeImage(2, 1, [
[0, 0, 0, 255], [0, 0, 0, 255],
[200, 200, 200, 255], [200, 200, 200, 255],
@@ -27,14 +27,14 @@ describe("sampleBilinear", () => {
expect(r).toBeCloseTo(100, 0); expect(r).toBeCloseTo(100, 0);
}); });
it("координаты за краем клампятся", () => { it("out-of-bounds coordinates are clamped", () => {
const img = makeImage(1, 1, [[7, 7, 7, 255]]); const img = makeImage(1, 1, [[7, 7, 7, 255]]);
expect(sampleBilinear(img, -10, -10)).toEqual([7, 7, 7, 255]); expect(sampleBilinear(img, -10, -10)).toEqual([7, 7, 7, 255]);
}); });
}); });
describe("rotateFreeImage", () => { describe("rotateFreeImage", () => {
it("поворот на 180° даёт размеры не меньше исходных и непустой результат", () => { it("180 deg rotation keeps size and fills result", () => {
const img = makeImage(4, 3, [ const img = makeImage(4, 3, [
[255, 0, 0, 255], [255, 0, 0, 255],
[0, 255, 0, 255], [0, 255, 0, 255],
@@ -60,7 +60,7 @@ describe("rotateFreeImage", () => {
expect(opaque / total).toBeGreaterThan(0.8); expect(opaque / total).toBeGreaterThan(0.8);
}); });
it("поворот квадрата на 360° близок к оригиналу", () => { it("360 deg rotation of square is close to original", () => {
const img = makeImage(5, 5, new Array(25).fill([200, 100, 50, 255])); const img = makeImage(5, 5, new Array(25).fill([200, 100, 50, 255]));
const out = rotateFreeImage(img, 360); const out = rotateFreeImage(img, 360);
expect(out.width).toBeGreaterThanOrEqual(5); expect(out.width).toBeGreaterThanOrEqual(5);
@@ -79,7 +79,7 @@ describe("rotateFreeImage", () => {
}); });
describe("skewImage", () => { describe("skewImage", () => {
it("наклон X=45° расширяет холст до w+h−1 и сдвигает строки вправо", () => { it("skew X=45 deg widens canvas to w+h-1 and shifts rows right", () => {
const img = makeImage(2, 2, new Array(4).fill([255, 0, 0, 255])); const img = makeImage(2, 2, new Array(4).fill([255, 0, 0, 255]));
const out = skewImage(img, 45, 0); const out = skewImage(img, 45, 0);
expect(out.width).toBe(3); expect(out.width).toBe(3);
@@ -96,7 +96,7 @@ describe("skewImage", () => {
expect(opaque).toBe(4); expect(opaque).toBe(4);
}); });
it("углы 0° — тождественное преобразование", () => { it("zero angles are identity transform", () => {
const img = makeImage(2, 2, [ const img = makeImage(2, 2, [
[255, 0, 0, 255], [255, 0, 0, 255],
[0, 255, 0, 255], [0, 255, 0, 255],
@@ -109,8 +109,8 @@ describe("skewImage", () => {
}); });
}); });
describe("transformImage — заливка фона", () => { describe("transformImage - background fill", () => {
it("сдвиг с белым фоном заполняет освободившийся край", () => { it("shift with white background fills freed edge", () => {
const img = makeImage(2, 2, [ const img = makeImage(2, 2, [
[255, 0, 0, 255], [255, 0, 0, 255],
[0, 255, 0, 255], [0, 255, 0, 255],
@@ -126,7 +126,7 @@ describe("transformImage — заливка фона", () => {
} }
}); });
it("без фона освободившийся край остаётся прозрачным", () => { it("without background freed edge stays transparent", () => {
const img = makeImage(2, 2, new Array(4).fill([10, 20, 30, 255])); const img = makeImage(2, 2, new Array(4).fill([10, 20, 30, 255]));
const out = transformImage(img, [1, 0, 0, 1, -1, 0], 2, 2); const out = transformImage(img, [1, 0, 0, 1, -1, 0], 2, 2);
expect(out.data[3]).toBe(0); expect(out.data[3]).toBe(0);
+18 -18
View File
@@ -13,7 +13,7 @@ import {
import { makeImage } from "./test-helpers"; import { makeImage } from "./test-helpers";
describe("hardenAlpha", () => { describe("hardenAlpha", () => {
it("бинаризует альфу по порогу, RGB не трогает", () => { it("binarizes alpha by threshold, keeps RGB", () => {
const out = hardenAlpha( const out = hardenAlpha(
makeImage(2, 1, [ makeImage(2, 1, [
[10, 20, 30, 100], [10, 20, 30, 100],
@@ -26,7 +26,7 @@ describe("hardenAlpha", () => {
}); });
describe("setAlphaChannel", () => { describe("setAlphaChannel", () => {
it("задаёт константную альфу", () => { it("sets constant alpha", () => {
const out = setAlphaChannel( const out = setAlphaChannel(
makeImage(2, 1, [ makeImage(2, 1, [
[1, 2, 3, 255], [1, 2, 3, 255],
@@ -41,7 +41,7 @@ describe("setAlphaChannel", () => {
}); });
describe("extractAlphaMask", () => { describe("extractAlphaMask", () => {
it("переводит альфу в чёрно-белую непрозрачную маску", () => { it("converts alpha to opaque black-white mask", () => {
const out = extractAlphaMask( const out = extractAlphaMask(
makeImage(2, 1, [ makeImage(2, 1, [
[10, 20, 30, 255], [10, 20, 30, 255],
@@ -53,7 +53,7 @@ describe("extractAlphaMask", () => {
}); });
describe("roundCorners", () => { describe("roundCorners", () => {
it("срезает углы, центр и середины сторон остаются", () => { it("cuts corners, center and edge midpoints stay", () => {
const img = makeImage(11, 11, new Array(121).fill([100, 100, 100, 255])); const img = makeImage(11, 11, new Array(121).fill([100, 100, 100, 255]));
const out = roundCorners(img, 40); const out = roundCorners(img, 40);
expect(out.data[(0 * 11 + 0) * 4 + 3]).toBe(0); expect(out.data[(0 * 11 + 0) * 4 + 3]).toBe(0);
@@ -61,14 +61,14 @@ describe("roundCorners", () => {
expect(out.data[(5 * 11 + 0) * 4 + 3]).toBe(255); expect(out.data[(5 * 11 + 0) * 4 + 3]).toBe(255);
}); });
it("нулевой радиус ничего не меняет", () => { it("zero radius changes nothing", () => {
const img = makeImage(2, 2, new Array(4).fill([1, 2, 3, 255])); const img = makeImage(2, 2, new Array(4).fill([1, 2, 3, 255]));
expect([...roundCorners(img, 0).data]).toEqual([...img.data]); expect([...roundCorners(img, 0).data]).toEqual([...img.data]);
}); });
}); });
describe("invertAlpha", () => { describe("invertAlpha", () => {
it("обращает альфу, RGB не трогает", () => { it("inverts alpha, keeps RGB", () => {
const out = invertAlpha(makeImage(1, 1, [[10, 20, 30, 128]])); const out = invertAlpha(makeImage(1, 1, [[10, 20, 30, 128]]));
expect([...out.data]).toEqual([10, 20, 30, 127]); expect([...out.data]).toEqual([10, 20, 30, 127]);
}); });
@@ -81,7 +81,7 @@ describe("removeColorToAlpha", () => {
[255, 0, 0, 255], [255, 0, 0, 255],
]); ]);
it("обнуляет альфу только у точного совпадения при tolerance=0", () => { it("zeroes alpha only on exact match at tolerance=0", () => {
const out = removeColorToAlpha(fixture(), "#ff0000", 0); const out = removeColorToAlpha(fixture(), "#ff0000", 0);
expect(out.data[3]).toBe(255); expect(out.data[3]).toBe(255);
expect(out.data[7]).toBe(0); expect(out.data[7]).toBe(0);
@@ -89,7 +89,7 @@ describe("removeColorToAlpha", () => {
expect(out.data[5]).toBe(0); expect(out.data[5]).toBe(0);
}); });
it("поддерживает короткую форму и регистр hex", () => { it("supports short form and hex case", () => {
expect([...removeColorToAlpha(fixture(), "#f00", 0).data.slice(4)]).toEqual( expect([...removeColorToAlpha(fixture(), "#f00", 0).data.slice(4)]).toEqual(
[255, 0, 0, 0], [255, 0, 0, 0],
); );
@@ -98,13 +98,13 @@ describe("removeColorToAlpha", () => {
]).toEqual([255, 0, 0, 0]); ]).toEqual([255, 0, 0, 0]);
}); });
it("tolerance 100% удаляет весь диапазон расстояний", () => { it("tolerance 100% removes full distance range", () => {
const out = removeColorToAlpha(fixture(), "#000000", 100); const out = removeColorToAlpha(fixture(), "#000000", 100);
expect(out.data[3]).toBe(0); expect(out.data[3]).toBe(0);
expect(out.data[7]).toBe(0); expect(out.data[7]).toBe(0);
}); });
it("промежуточный tolerance различает близкие и далёкие цвета", () => { it("intermediate tolerance distinguishes near and far colors", () => {
const img = makeImage(2, 1, [ const img = makeImage(2, 1, [
[0, 0, 0, 255], [0, 0, 0, 255],
[128, 128, 128, 255], [128, 128, 128, 255],
@@ -117,7 +117,7 @@ describe("removeColorToAlpha", () => {
expect(removed.data[7]).toBe(0); expect(removed.data[7]).toBe(0);
}); });
it("не мутирует вход", () => { it("does not mutate input", () => {
const img = fixture(); const img = fixture();
removeColorToAlpha(img, "#ffffff", 100); removeColorToAlpha(img, "#ffffff", 100);
expect([...img.data]).toEqual([255, 255, 255, 255, 255, 0, 0, 255]); expect([...img.data]).toEqual([255, 255, 255, 255, 255, 0, 0, 255]);
@@ -125,7 +125,7 @@ describe("removeColorToAlpha", () => {
}); });
describe("colorMask", () => { describe("colorMask", () => {
it("удаляемые пиксели белые, остальные чёрные, маска непрозрачная", () => { it("removed pixels white, rest black, mask opaque", () => {
const out = colorMask( const out = colorMask(
makeImage(2, 1, [ makeImage(2, 1, [
[255, 0, 0, 255], [255, 0, 0, 255],
@@ -137,7 +137,7 @@ describe("colorMask", () => {
expect([...out.data]).toEqual([255, 255, 255, 255, 0, 0, 0, 255]); expect([...out.data]).toEqual([255, 255, 255, 255, 0, 0, 0, 255]);
}); });
it("порог совпадает с removeColorToAlpha", () => { it("threshold matches removeColorToAlpha", () => {
const img = makeImage(2, 1, [ const img = makeImage(2, 1, [
[0, 0, 0, 255], [0, 0, 0, 255],
[128, 128, 128, 255], [128, 128, 128, 255],
@@ -150,7 +150,7 @@ describe("colorMask", () => {
}); });
describe("flattenOntoColor", () => { describe("flattenOntoColor", () => {
it("непрозрачный пиксель не меняется, альфа становится 255", () => { it("opaque pixel unchanged, alpha becomes 255", () => {
const out = flattenOntoColor( const out = flattenOntoColor(
makeImage(1, 1, [[10, 20, 30, 255]]), makeImage(1, 1, [[10, 20, 30, 255]]),
"#ffffff", "#ffffff",
@@ -158,12 +158,12 @@ describe("flattenOntoColor", () => {
expect([...out.data]).toEqual([10, 20, 30, 255]); expect([...out.data]).toEqual([10, 20, 30, 255]);
}); });
it("полностью прозрачный пиксель становится цветом подложки", () => { it("fully transparent pixel becomes backdrop color", () => {
const out = flattenOntoColor(makeImage(1, 1, [[99, 99, 99, 0]]), "#ff8040"); const out = flattenOntoColor(makeImage(1, 1, [[99, 99, 99, 0]]), "#ff8040");
expect([...out.data]).toEqual([255, 128, 64, 255]); expect([...out.data]).toEqual([255, 128, 64, 255]);
}); });
it("полупрозрачный пиксель смешивается с подложкой", () => { it("semi-transparent pixel blends with backdrop", () => {
const out = flattenOntoColor( const out = flattenOntoColor(
makeImage(1, 1, [[10, 20, 30, 128]]), makeImage(1, 1, [[10, 20, 30, 128]]),
"#ffffff", "#ffffff",
@@ -173,13 +173,13 @@ describe("flattenOntoColor", () => {
}); });
describe("parseHex", () => { describe("parseHex", () => {
it("разбирает #rrggbb, rrggbb, #rgb", () => { it("parses #rrggbb, rrggbb, #rgb", () => {
expect(parseHex("#ff8040")).toEqual([255, 128, 64]); expect(parseHex("#ff8040")).toEqual([255, 128, 64]);
expect(parseHex("ff8040")).toEqual([255, 128, 64]); expect(parseHex("ff8040")).toEqual([255, 128, 64]);
expect(parseHex("#F80")).toEqual([255, 136, 0]); expect(parseHex("#F80")).toEqual([255, 136, 0]);
}); });
it.each(["zzz", "12345", "##ff", ""])('бросает ошибку на "%s"', (bad) => { it.each(["zzz", "12345", "##ff", ""])('throws on "%s"', (bad) => {
expect(() => parseHex(bad)).toThrow(/errors\.badHex/); expect(() => parseHex(bad)).toThrow(/errors\.badHex/);
}); });
}); });
+11 -11
View File
@@ -8,7 +8,7 @@ import {
import { makeImage } from "./test-helpers"; import { makeImage } from "./test-helpers";
describe("imageInfo", () => { describe("imageInfo", () => {
it("находит полупрозрачные пиксели и считает уникальные RGBA-цвета", () => { it("finds semi-transparent pixels and counts unique RGBA colors", () => {
const info = imageInfo( const info = imageInfo(
makeImage(2, 2, [ makeImage(2, 2, [
[0, 0, 0, 255], [0, 0, 0, 255],
@@ -25,7 +25,7 @@ describe("imageInfo", () => {
}); });
}); });
it("полностью непрозрачное изображение — hasAlpha false", () => { it("fully opaque image - hasAlpha false", () => {
const info = imageInfo( const info = imageInfo(
makeImage(2, 1, [ makeImage(2, 1, [
[10, 20, 30, 255], [10, 20, 30, 255],
@@ -36,7 +36,7 @@ describe("imageInfo", () => {
expect(info.colorCount).toBe(2); expect(info.colorCount).toBe(2);
}); });
it("разная альфа означает разные цвета", () => { it("different alpha means different colors", () => {
const info = imageInfo( const info = imageInfo(
makeImage(2, 1, [ makeImage(2, 1, [
[255, 0, 0, 255], [255, 0, 0, 255],
@@ -47,7 +47,7 @@ describe("imageInfo", () => {
expect(info.colorCount).toBe(2); expect(info.colorCount).toBe(2);
}); });
it("возвращает корректные размеры неквадрата", () => { it("returns correct sizes for non-square image", () => {
const info = imageInfo(makeImage(5, 3, new Array(15).fill([1, 2, 3, 4]))); const info = imageInfo(makeImage(5, 3, new Array(15).fill([1, 2, 3, 4])));
expect(info.width).toBe(5); expect(info.width).toBe(5);
expect(info.height).toBe(3); expect(info.height).toBe(3);
@@ -55,25 +55,25 @@ describe("imageInfo", () => {
}); });
describe("isGrayscale", () => { describe("isGrayscale", () => {
it("серые пиксели — монохром", () => { it("gray pixels are monochrome", () => {
expect(isGrayscale(makeImage(1, 1, [[10, 10, 10, 255]]))).toBe(true); expect(isGrayscale(makeImage(1, 1, [[10, 10, 10, 255]]))).toBe(true);
}); });
it("цветной пиксель ломает монохром", () => { it("colored pixel breaks monochrome", () => {
expect(isGrayscale(makeImage(1, 1, [[10, 11, 10, 255]]))).toBe(false); expect(isGrayscale(makeImage(1, 1, [[10, 11, 10, 255]]))).toBe(false);
}); });
}); });
describe("hasTransparency", () => { describe("hasTransparency", () => {
it("альфа ниже 255 — прозрачность есть", () => { it("alpha below 255 means transparency", () => {
expect(hasTransparency(makeImage(1, 1, [[0, 0, 0, 254]]))).toBe(true); expect(hasTransparency(makeImage(1, 1, [[0, 0, 0, 254]]))).toBe(true);
}); });
it("все пиксели непрозрачны", () => { it("all pixels opaque", () => {
expect(hasTransparency(makeImage(1, 1, [[0, 0, 0, 255]]))).toBe(false); expect(hasTransparency(makeImage(1, 1, [[0, 0, 0, 255]]))).toBe(false);
}); });
}); });
describe("special decoded pixel data", () => { describe("special decoded pixel data", () => {
it("палитра после browser-декодирования сохраняет количество цветов", () => { it("palette keeps color count after browser decode", () => {
const image = makeImage(2, 2, [ const image = makeImage(2, 2, [
[255, 0, 0, 255], [255, 0, 0, 255],
[0, 255, 0, 255], [0, 255, 0, 255],
@@ -89,7 +89,7 @@ describe("special decoded pixel data", () => {
}); });
}); });
it("16-bit после downscale остаётся цветным и непрозрачным", () => { it("16-bit stays colored and opaque after downscale", () => {
const image = makeImage( const image = makeImage(
4, 4,
4, 4,
@@ -107,7 +107,7 @@ describe("special decoded pixel data", () => {
}); });
describe("orientationOf", () => { describe("orientationOf", () => {
it("определяет ориентацию", () => { it("detects orientation", () => {
expect( expect(
orientationOf(makeImage(2, 3, new Array(6).fill([1, 1, 1, 1]))), orientationOf(makeImage(2, 3, new Array(6).fill([1, 1, 1, 1]))),
).toBe("portrait"); ).toBe("portrait");
+10 -10
View File
@@ -10,7 +10,7 @@ const GREEN = [0, 255, 0, 255];
const RED = [255, 0, 0, 255]; const RED = [255, 0, 0, 255];
describe("backgroundRemovalMask", () => { describe("backgroundRemovalMask", () => {
it("глобальный режим удаляет все совпадающие пиксели", () => { it("global mode removes all matching pixels", () => {
const mask = backgroundRemovalMask(makeImage(2, 1, [GREEN, RED]), { const mask = backgroundRemovalMask(makeImage(2, 1, [GREEN, RED]), {
color: "#00ff00", color: "#00ff00",
tolerancePercent: 0, tolerancePercent: 0,
@@ -20,10 +20,10 @@ describe("backgroundRemovalMask", () => {
expect([...mask]).toEqual([1, 0]); expect([...mask]).toEqual([1, 0]);
}); });
describe("режим внешних областей", () => { describe("outer regions mode", () => {
const ringRedCenterGreen = [RED, RED, RED, RED, GREEN, RED, RED, RED, RED]; const ringRedCenterGreen = [RED, RED, RED, RED, GREEN, RED, RED, RED, RED];
it("заливка от краёв не достаёт до изолированного совпадающего острова", () => { it("edge flood cannot reach isolated matching island", () => {
const mask = backgroundRemovalMask(makeImage(3, 3, ringRedCenterGreen), { const mask = backgroundRemovalMask(makeImage(3, 3, ringRedCenterGreen), {
color: "#00ff00", color: "#00ff00",
tolerancePercent: 0, tolerancePercent: 0,
@@ -33,7 +33,7 @@ describe("backgroundRemovalMask", () => {
expect(mask[4]).toBe(0); expect(mask[4]).toBe(0);
}); });
it("глобальный режим удаляет и изолированный остров", () => { it("global mode removes isolated island too", () => {
const mask = backgroundRemovalMask(makeImage(3, 3, ringRedCenterGreen), { const mask = backgroundRemovalMask(makeImage(3, 3, ringRedCenterGreen), {
color: "#00ff00", color: "#00ff00",
tolerancePercent: 0, tolerancePercent: 0,
@@ -45,7 +45,7 @@ describe("backgroundRemovalMask", () => {
}); });
}); });
it("допуск расширяет захват по цветовому расстоянию", () => { it("tolerance widens capture by color distance", () => {
const img = makeImage(2, 1, [ const img = makeImage(2, 1, [
[10, 10, 10, 255], [10, 10, 10, 255],
[128, 128, 128, 255], [128, 128, 128, 255],
@@ -67,13 +67,13 @@ describe("backgroundRemovalMask", () => {
}); });
}); });
describe("smoothMask-поведение через backgroundRemovalMask", () => { describe("smoothMask behavior via backgroundRemovalMask", () => {
const ringRedCenterGreen = [RED, RED, RED, RED, GREEN, RED, RED, RED, RED]; const ringRedCenterGreen = [RED, RED, RED, RED, GREEN, RED, RED, RED, RED];
const opts = { color: "#00ff00", tolerancePercent: 0, outerOnly: false }; const opts = { color: "#00ff00", tolerancePercent: 0, outerOnly: false };
const alphaAtCenter = (img: { data: Uint8ClampedArray }) => img.data[19]; const alphaAtCenter = (img: { data: Uint8ClampedArray }) => img.data[19];
it("без сглаживания центр удалён", () => { it("without smoothing center is removed", () => {
const img = removeBackground(makeImage(3, 3, ringRedCenterGreen), { const img = removeBackground(makeImage(3, 3, ringRedCenterGreen), {
...opts, ...opts,
smoothPasses: 0, smoothPasses: 0,
@@ -81,7 +81,7 @@ describe("smoothMask-поведение через backgroundRemovalMask", () =>
expect(alphaAtCenter(img)).toBe(0); expect(alphaAtCenter(img)).toBe(0);
}); });
it("два прохода мажоритарного фильтра возвращают изолированный пиксель", () => { it("two majority filter passes restore isolated pixel", () => {
const img = removeBackground(makeImage(3, 3, ringRedCenterGreen), { const img = removeBackground(makeImage(3, 3, ringRedCenterGreen), {
...opts, ...opts,
smoothPasses: 2, smoothPasses: 2,
@@ -91,7 +91,7 @@ describe("smoothMask-поведение через backgroundRemovalMask", () =>
}); });
describe("removeBackground", () => { describe("removeBackground", () => {
it("обнуляет альфу удалённых, сохраняет RGB остальных", () => { it("zeroes alpha of removed, keeps RGB of rest", () => {
const out = removeBackground(makeImage(2, 1, [GREEN, [5, 6, 7, 200]]), { const out = removeBackground(makeImage(2, 1, [GREEN, [5, 6, 7, 200]]), {
color: "#00ff00", color: "#00ff00",
tolerancePercent: 0, tolerancePercent: 0,
@@ -104,7 +104,7 @@ describe("removeBackground", () => {
}); });
describe("backgroundMaskPreview", () => { describe("backgroundMaskPreview", () => {
it("белое там, где удаление, чёрное — где остаёмся, всё непрозрачно", () => { it("white where removed, black where kept, all opaque", () => {
const preview = backgroundMaskPreview( const preview = backgroundMaskPreview(
makeImage(2, 1, [GREEN, [9, 9, 9, 60]]), makeImage(2, 1, [GREEN, [9, 9, 9, 60]]),
{ {
+3 -3
View File
@@ -3,7 +3,7 @@ import { encodeBmpBytes } from "./bmp";
import { makeImage } from "./test-helpers"; import { makeImage } from "./test-helpers";
describe("encodeBmpBytes", () => { describe("encodeBmpBytes", () => {
it("пишет заголовки BM и размеры для 2x1 без паддинга-неопределённости", () => { it("writes BM headers and sizes for 2x1 without padding ambiguity", () => {
const bytes = encodeBmpBytes( const bytes = encodeBmpBytes(
makeImage(2, 1, [ makeImage(2, 1, [
[255, 0, 0, 255], [255, 0, 0, 255],
@@ -18,7 +18,7 @@ describe("encodeBmpBytes", () => {
expect(view.getUint16(28, true)).toBe(24); expect(view.getUint16(28, true)).toBe(24);
}); });
it("хранит пиксели в BGR снизу-вверх с паддингом строки", () => { it("stores pixels as BGR bottom-up with row padding", () => {
const bytes = encodeBmpBytes( const bytes = encodeBmpBytes(
makeImage(2, 1, [ makeImage(2, 1, [
[255, 0, 0, 255], [255, 0, 0, 255],
@@ -30,7 +30,7 @@ describe("encodeBmpBytes", () => {
expect(bytes[61]).toBe(0); expect(bytes[61]).toBe(0);
}); });
it("нижняя строка изображения идёт первой в файле", () => { it("bottom image row comes first in file", () => {
const bytes = encodeBmpBytes( const bytes = encodeBmpBytes(
makeImage(4, 2, [ makeImage(4, 2, [
[10, 10, 10, 255], [10, 10, 10, 255],
+10 -10
View File
@@ -3,39 +3,39 @@ import { hexToRgb } from "./palette";
import { renderSpace, SPACES } from "./channels"; import { renderSpace, SPACES } from "./channels";
import { makeImage } from "./test-helpers"; import { makeImage } from "./test-helpers";
describe("преобразования пространств", () => { describe("space conversions", () => {
it("hsl красного: h=0, s=1, l=0.5", () => { it("hsl of red: h=0, s=1, l=0.5", () => {
const [h, s, l] = SPACES.hsl.convert(hexToRgb("#ff0000")); const [h, s, l] = SPACES.hsl.convert(hexToRgb("#ff0000"));
expect(h).toBeCloseTo(0); expect(h).toBeCloseTo(0);
expect(s).toBeCloseTo(1); expect(s).toBeCloseTo(1);
expect(l).toBeCloseTo(0.5); expect(l).toBeCloseTo(0.5);
}); });
it("hsv белого: v=1, чёрного: v=0", () => { it("hsv of white: v=1, of black: v=0", () => {
expect(SPACES.hsv.convert({ r: 255, g: 255, b: 255 })[2]).toBeCloseTo(1); expect(SPACES.hsv.convert({ r: 255, g: 255, b: 255 })[2]).toBeCloseTo(1);
expect(SPACES.hsv.convert({ r: 0, g: 0, b: 0 })[2]).toBe(0); expect(SPACES.hsv.convert({ r: 0, g: 0, b: 0 })[2]).toBe(0);
}); });
it("hsi серого: s=0", () => { it("hsi of gray: s=0", () => {
const [, s] = SPACES.hsi.convert({ r: 128, g: 128, b: 128 }); const [, s] = SPACES.hsi.convert({ r: 128, g: 128, b: 128 });
expect(s).toBeCloseTo(0); expect(s).toBeCloseTo(0);
}); });
it("cmyk: белый — 0,0,0,0; чёрный — 0,0,0,1", () => { it("cmyk: white - 0,0,0,0; black - 0,0,0,1", () => {
const w = SPACES.cmyk.convert({ r: 255, g: 255, b: 255 }); const w = SPACES.cmyk.convert({ r: 255, g: 255, b: 255 });
const b = SPACES.cmyk.convert({ r: 0, g: 0, b: 0 }); const b = SPACES.cmyk.convert({ r: 0, g: 0, b: 0 });
expect(w.every((v) => Math.abs(v) < 1e-9)).toBe(true); expect(w.every((v) => Math.abs(v) < 1e-9)).toBe(true);
expect(b[3]).toBeCloseTo(1); expect(b[3]).toBeCloseTo(1);
}); });
it("ycbcr белого: y≈1, cb≈cr≈0.5", () => { it("ycbcr of white: y~1, cb~cr~0.5", () => {
const [y, cb, cr] = SPACES.ycbcr.convert({ r: 255, g: 255, b: 255 }); const [y, cb, cr] = SPACES.ycbcr.convert({ r: 255, g: 255, b: 255 });
expect(y).toBeCloseTo(1); expect(y).toBeCloseTo(1);
expect(cb).toBeCloseTo(0.5, 2); expect(cb).toBeCloseTo(0.5, 2);
expect(cr).toBeCloseTo(0.5, 2); expect(cr).toBeCloseTo(0.5, 2);
}); });
it("lab белого: l≈1, a≈b≈0.5 (центр)", () => { it("lab of white: l~1, a~b~0.5 (center)", () => {
const [l, a, bb] = SPACES.lab.convert({ r: 255, g: 255, b: 255 }); const [l, a, bb] = SPACES.lab.convert({ r: 255, g: 255, b: 255 });
expect(l).toBeCloseTo(1, 2); expect(l).toBeCloseTo(1, 2);
expect(a).toBeCloseTo(0.5, 2); expect(a).toBeCloseTo(0.5, 2);
@@ -46,19 +46,19 @@ describe("преобразования пространств", () => {
describe("renderSpace", () => { describe("renderSpace", () => {
const red = makeImage(1, 1, [[255, 0, 0, 255]]); const red = makeImage(1, 1, [[255, 0, 0, 255]]);
it("gray-режим: компонент y (жёлтый) чистого красного = белый", () => { it("gray mode: y (yellow) component of pure red = white", () => {
const out = renderSpace(red, "cmyk", "y", "gray"); const out = renderSpace(red, "cmyk", "y", "gray");
expect([...out.data]).toEqual([255, 255, 255, 255]); expect([...out.data]).toEqual([255, 255, 255, 255]);
}); });
it("color-режим hsl красного: каналы = тон/насыщенность/светлота", () => { it("color mode hsl of red: channels = hue/saturation/lightness", () => {
const out = renderSpace(red, "hsl", "s", "color"); const out = renderSpace(red, "hsl", "s", "color");
expect(out.data[0]).toBe(0); expect(out.data[0]).toBe(0);
expect(out.data[1]).toBe(255); expect(out.data[1]).toBe(255);
expect(out.data[2]).toBeGreaterThanOrEqual(127); expect(out.data[2]).toBeGreaterThanOrEqual(127);
}); });
it("неизвестное пространство или компонент дают прозрачную заглушку", () => { it("unknown space or component yields transparent placeholder", () => {
const junk = makeImage(1, 1, [[10, 20, 30, 255]]); const junk = makeImage(1, 1, [[10, 20, 30, 255]]);
expect( expect(
renderSpace(junk, "cmyk" as never, "zz" as never, "gray").data[3], renderSpace(junk, "cmyk" as never, "zz" as never, "gray").data[3],
+5 -5
View File
@@ -3,7 +3,7 @@ import { createPixelImage } from "./types";
import { rgbToHsl } from "./palette"; import { rgbToHsl } from "./palette";
import type { Rgb } from "./palette"; import type { Rgb } from "./palette";
/** Все компоненты нормализованы в 0..1 в порядке объявления. */ /** All components normalized to 0..1 in declaration order. */
export type SpaceComponents = number[]; export type SpaceComponents = number[];
export type SpaceId = "hsl" | "hsv" | "hsi" | "cmyk" | "ycbcr" | "lab"; export type SpaceId = "hsl" | "hsv" | "hsi" | "cmyk" | "ycbcr" | "lab";
@@ -75,7 +75,7 @@ function rgbToLab({ r, g, b }: Rgb): [number, number, number] {
const fx = f(x); const fx = f(x);
const fy = f(y); const fy = f(y);
const fz = f(z); const fz = f(z);
// L нормирован 0..1; a/b центрированы на 0.5 с размахом ±0.5 // L normalized to 0..1; a/b centered at 0.5 with +-0.5 range
return [ return [
(116 * fy - 16) / 100, (116 * fy - 16) / 100,
(500 * (fx - fy)) / 250 + 0.5, (500 * (fx - fy)) / 250 + 0.5,
@@ -106,9 +106,9 @@ export const SPACES: Record<SpaceId, SpaceDef> = {
export type ChannelDisplay = "gray" | "color"; export type ChannelDisplay = "gray" | "color";
/** /**
* Визуализация выбранного пространства: каждый компонент пространства * Visualizes the selected space: each component goes to its own result
* попадает в свой канал результата (mode 'color') или выбранный компонент * channel (mode 'color'), or the selected component is drawn in grayscale
* рисуется градациями серого (mode 'gray'). * (mode 'gray').
*/ */
export function renderSpace( export function renderSpace(
img: PixelImage, img: PixelImage,
+28 -28
View File
@@ -20,23 +20,23 @@ import {
import { makeImage } from "./test-helpers"; import { makeImage } from "./test-helpers";
describe("rgbToHex", () => { describe("rgbToHex", () => {
it("форматирует базовые цвета", () => { it("formats base colors", () => {
expect(rgbToHex(255, 0, 0)).toBe("#ff0000"); expect(rgbToHex(255, 0, 0)).toBe("#ff0000");
expect(rgbToHex(1, 2, 3)).toBe("#010203"); expect(rgbToHex(1, 2, 3)).toBe("#010203");
}); });
it("округляет дробные значения и клампит диапазон", () => { it("rounds fractional values and clamps range", () => {
expect(rgbToHex(127.6, -5, 300)).toBe("#8000ff"); expect(rgbToHex(127.6, -5, 300)).toBe("#8000ff");
}); });
}); });
describe("setOpacity", () => { describe("setOpacity", () => {
it("умножает альфу на процент, RGB не трогает", () => { it("multiplies alpha by percent, keeps RGB", () => {
const out = setOpacity(makeImage(1, 1, [[10, 20, 30, 128]]), 50); const out = setOpacity(makeImage(1, 1, [[10, 20, 30, 128]]), 50);
expect([...out.data]).toEqual([10, 20, 30, 64]); expect([...out.data]).toEqual([10, 20, 30, 64]);
}); });
it("100% не меняет, 0% делает полностью прозрачным", () => { it("100% keeps alpha, 0% makes fully transparent", () => {
expect(setOpacity(makeImage(1, 1, [[1, 2, 3, 200]]), 100).data[3]).toBe( expect(setOpacity(makeImage(1, 1, [[1, 2, 3, 200]]), 100).data[3]).toBe(
200, 200,
); );
@@ -45,7 +45,7 @@ describe("setOpacity", () => {
}); });
describe("sepia", () => { describe("sepia", () => {
it("применяет классическую матрицу с клампом", () => { it("applies classic matrix with clamping", () => {
const out = sepia( const out = sepia(
makeImage(2, 1, [ makeImage(2, 1, [
[255, 0, 0, 255], [255, 0, 0, 255],
@@ -60,26 +60,26 @@ describe("sepia", () => {
}); });
describe("changeHue", () => { describe("changeHue", () => {
it("чистый красный при +120° становится чистым зелёным", () => { it("pure red at +120 deg becomes pure green", () => {
const out = changeHue(makeImage(1, 1, [[255, 0, 0, 255]]), 120); const out = changeHue(makeImage(1, 1, [[255, 0, 0, 255]]), 120);
expect([...out.data]).toEqual([0, 255, 0, 255]); expect([...out.data]).toEqual([0, 255, 0, 255]);
}); });
it("сдвиг 360° возвращает исходные цвета", () => { it("360 deg shift returns original colors", () => {
const img = makeImage(1, 1, [[90, 140, 210, 255]]); const img = makeImage(1, 1, [[90, 140, 210, 255]]);
expect([...changeHue(img, 360).data]).toEqual([...img.data]); expect([...changeHue(img, 360).data]).toEqual([...img.data]);
}); });
}); });
describe("extractChannel", () => { describe("extractChannel", () => {
it("выдаёт выбранный канал оттенками серого", () => { it("renders selected channel in grayscale", () => {
const out = extractChannel(makeImage(1, 1, [[10, 20, 30, 40]]), "green"); const out = extractChannel(makeImage(1, 1, [[10, 20, 30, 40]]), "green");
expect([...out.data]).toEqual([20, 20, 20, 40]); expect([...out.data]).toEqual([20, 20, 20, 40]);
}); });
}); });
describe("swapChannels", () => { describe("swapChannels", () => {
it("переставляет каналы парами", () => { it("swaps channels in pairs", () => {
expect([ expect([
...swapChannels(makeImage(1, 1, [[10, 20, 30, 40]]), "r-b").data, ...swapChannels(makeImage(1, 1, [[10, 20, 30, 40]]), "r-b").data,
]).toEqual([30, 20, 10, 40]); ]).toEqual([30, 20, 10, 40]);
@@ -90,7 +90,7 @@ describe("swapChannels", () => {
}); });
describe("thresholdBlackWhite", () => { describe("thresholdBlackWhite", () => {
it("серый 128 относительно порога 50% — белый", () => { it("gray 128 vs 50% threshold - white", () => {
expect( expect(
thresholdBlackWhite(makeImage(1, 1, [[128, 128, 128, 255]]), 50).data[0], thresholdBlackWhite(makeImage(1, 1, [[128, 128, 128, 255]]), 50).data[0],
).toBe(255); ).toBe(255);
@@ -101,7 +101,7 @@ describe("thresholdBlackWhite", () => {
}); });
describe("posterize", () => { describe("posterize", () => {
it("два уровня квантуют в чёрное и белое", () => { it("two levels quantize to black and white", () => {
const out = posterize( const out = posterize(
makeImage(2, 1, [ makeImage(2, 1, [
[100, 100, 100, 255], [100, 100, 100, 255],
@@ -115,7 +115,7 @@ describe("posterize", () => {
}); });
describe("twoColors", () => { describe("twoColors", () => {
it("яркие пиксели получают светлый цвет, тёмные — тёмный", () => { it("bright pixels get light color, dark get dark", () => {
const out = twoColors( const out = twoColors(
makeImage(2, 1, [ makeImage(2, 1, [
[250, 250, 250, 255], [250, 250, 250, 255],
@@ -131,7 +131,7 @@ describe("twoColors", () => {
}); });
describe("grayscale", () => { describe("grayscale", () => {
it("считает luma по весам BT.601 с округлением", () => { it("computes luma with BT.601 weights and rounding", () => {
const out = grayscale( const out = grayscale(
makeImage(3, 1, [ makeImage(3, 1, [
[255, 0, 0, 255], [255, 0, 0, 255],
@@ -146,7 +146,7 @@ describe("grayscale", () => {
expect(rgb).toEqual([76, 150, 29]); expect(rgb).toEqual([76, 150, 29]);
}); });
it("сохраняет альфу и не мутирует вход", () => { it("keeps alpha and does not mutate input", () => {
const img = makeImage(1, 1, [[10, 20, 30, 200]]); const img = makeImage(1, 1, [[10, 20, 30, 200]]);
const out = grayscale(img); const out = grayscale(img);
expect([...out.data]).toEqual([18, 18, 18, 200]); expect([...out.data]).toEqual([18, 18, 18, 200]);
@@ -155,7 +155,7 @@ describe("grayscale", () => {
}); });
describe("invert", () => { describe("invert", () => {
it("инвертирует RGB, не трогая альфу", () => { it("inverts RGB, keeps alpha", () => {
const out = invert(makeImage(1, 1, [[10, 200, 30, 7]])); const out = invert(makeImage(1, 1, [[10, 200, 30, 7]]));
expect([...out.data]).toEqual([245, 55, 225, 7]); expect([...out.data]).toEqual([245, 55, 225, 7]);
}); });
@@ -165,50 +165,50 @@ describe("brightnessContrast", () => {
const pixel = (r: number) => makeImage(1, 1, [[r, r, r, 255]]); const pixel = (r: number) => makeImage(1, 1, [[r, r, r, 255]]);
const red = (out: number[]) => [out[0], out[1], out[2]]; const red = (out: number[]) => [out[0], out[1], out[2]];
it("b=0, c=0 — тождественное преобразование", () => { it("b=0, c=0 - identity transform", () => {
const out = brightnessContrast(pixel(77), 0, 0); const out = brightnessContrast(pixel(77), 0, 0);
expect(red([...out.data])).toEqual([77, 77, 77]); expect(red([...out.data])).toEqual([77, 77, 77]);
}); });
it("brightness +100 насыщает всё в белый", () => { it("brightness +100 saturates all to white", () => {
const out = brightnessContrast(pixel(10), 100, 0); const out = brightnessContrast(pixel(10), 100, 0);
expect(red([...out.data])).toEqual([255, 255, 255]); expect(red([...out.data])).toEqual([255, 255, 255]);
}); });
it("brightness -100 заливает чёрным", () => { it("brightness -100 fills with black", () => {
const out = brightnessContrast(pixel(240), -100, 0); const out = brightnessContrast(pixel(240), -100, 0);
expect(red([...out.data])).toEqual([0, 0, 0]); expect(red([...out.data])).toEqual([0, 0, 0]);
}); });
it("contrast -100 сводит всё к серому 128", () => { it("contrast -100 collapses all to gray 128", () => {
const out = brightnessContrast(pixel(30), 0, -100); const out = brightnessContrast(pixel(30), 0, -100);
expect(red([...out.data])).toEqual([128, 128, 128]); expect(red([...out.data])).toEqual([128, 128, 128]);
}); });
it("параметры вне диапазона клампятся", () => { it("out-of-range params are clamped", () => {
const out = brightnessContrast(pixel(10), 150, 0); const out = brightnessContrast(pixel(10), 150, 0);
expect(red([...out.data])).toEqual([255, 255, 255]); expect(red([...out.data])).toEqual([255, 255, 255]);
}); });
it("альфа не меняется", () => { it("alpha unchanged", () => {
const out = brightnessContrast(makeImage(1, 1, [[10, 20, 30, 64]]), 50, 50); const out = brightnessContrast(makeImage(1, 1, [[10, 20, 30, 64]]), 50, 50);
expect(out.data[3]).toBe(64); expect(out.data[3]).toBe(64);
}); });
}); });
describe("gammaCorrection", () => { describe("gammaCorrection", () => {
it("гамма 1 — идентичность", () => { it("gamma 1 - identity", () => {
const img = makeImage(1, 1, [[64, 128, 192, 255]]); const img = makeImage(1, 1, [[64, 128, 192, 255]]);
expect([...gammaCorrection(img, 1).data]).toEqual([64, 128, 192, 255]); expect([...gammaCorrection(img, 1).data]).toEqual([64, 128, 192, 255]);
}); });
it("гамма 2 удваивает яркость (64 → 128)", () => { it("gamma 2 doubles brightness (64 -> 128)", () => {
const out = gammaCorrection(makeImage(1, 1, [[64, 64, 64, 255]]), 2); const out = gammaCorrection(makeImage(1, 1, [[64, 64, 64, 255]]), 2);
expect(out.data[0]).toBe(128); expect(out.data[0]).toBe(128);
}); });
}); });
describe("autoContrast", () => { describe("autoContrast", () => {
it("растягивает диапазон [10..200] до [0..255]", () => { it("stretches range [10..200] to [0..255]", () => {
const out = autoContrast( const out = autoContrast(
makeImage(2, 1, [ makeImage(2, 1, [
[10, 10, 10, 255], [10, 10, 10, 255],
@@ -221,23 +221,23 @@ describe("autoContrast", () => {
}); });
describe("temperature", () => { describe("temperature", () => {
it("положительная — теплее (красный ↑, синий ↓)", () => { it("positive - warmer (red up, blue down)", () => {
const out = temperature(makeImage(1, 1, [[128, 128, 128, 255]]), 50); const out = temperature(makeImage(1, 1, [[128, 128, 128, 255]]), 50);
expect(out.data[0]).toBeGreaterThan(128); expect(out.data[0]).toBeGreaterThan(128);
expect(out.data[2]).toBeLessThan(128); expect(out.data[2]).toBeLessThan(128);
}); });
it("нулевая температура не меняет", () => { it("zero temperature changes nothing", () => {
const img = makeImage(1, 1, [[128, 128, 128, 255]]); const img = makeImage(1, 1, [[128, 128, 128, 255]]);
expect([...temperature(img, 0).data]).toEqual([...img.data]); expect([...temperature(img, 0).data]).toEqual([...img.data]);
}); });
}); });
describe("tint", () => { describe("tint", () => {
it("сила 100 на белом даёт чистый цвет", () => { it("strength 100 on white yields pure color", () => {
const out = tint(makeImage(1, 1, [[255, 255, 255, 255]]), "#ff0000", 100); const out = tint(makeImage(1, 1, [[255, 255, 255, 255]]), "#ff0000", 100);
expect([...out.data]).toEqual([255, 0, 0, 255]); expect([...out.data]).toEqual([255, 0, 0, 255]);
}); });
it("сила 0 — идентичность", () => { it("strength 0 - identity", () => {
const img = makeImage(1, 1, [[100, 150, 200, 255]]); const img = makeImage(1, 1, [[100, 150, 200, 255]]);
expect([...tint(img, "#ff0000", 0).data]).toEqual([...img.data]); expect([...tint(img, "#ff0000", 0).data]).toEqual([...img.data]);
}); });
+47 -8
View File
@@ -1,8 +1,12 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { COMPRESSION_LEVELS, findMaxColorsWithin } from "./compress"; import {
COMPRESSION_LEVELS,
findMaxColorsWithin,
findQualityWithin,
} from "./compress";
describe("COMPRESSION_LEVELS", () => { describe("COMPRESSION_LEVELS", () => {
it("пресеты упорядочены по убыванию цветов", () => { it("presets ordered by descending colors", () => {
const values = Object.values(COMPRESSION_LEVELS); const values = Object.values(COMPRESSION_LEVELS);
for (let i = 1; i < values.length; i++) for (let i = 1; i < values.length; i++)
expect(values[i - 1]).toBeGreaterThan(values[i]); expect(values[i - 1]).toBeGreaterThan(values[i]);
@@ -10,26 +14,26 @@ describe("COMPRESSION_LEVELS", () => {
}); });
describe("findMaxColorsWithin", () => { describe("findMaxColorsWithin", () => {
const sizeOf = (k: number) => 1000 + k * 100; // размер растёт с k const sizeOf = (k: number) => 1000 + k * 100; // size grows with k
it("подбирает максимум k, укладывающийся в цель", async () => { it("picks max k fitting the target", async () => {
// target 5200 → подходит k≤42 → ожидаем 42 при maxK≥42 // target 5200 -> k<=42 fits -> expect 42 at maxK>=42
const k = await findMaxColorsWithin(5200, 256, async (kk) => sizeOf(kk)); const k = await findMaxColorsWithin(5200, 256, async (kk) => sizeOf(kk));
expect(k).toBeGreaterThanOrEqual(40); expect(k).toBeGreaterThanOrEqual(40);
expect(sizeOf(k)).toBeLessThanOrEqual(5200); expect(sizeOf(k)).toBeLessThanOrEqual(5200);
}); });
it("цель достигается даже на минимуме — возвращает 2", async () => { it("target unreachable even at minimum - returns 2", async () => {
const k = await findMaxColorsWithin(50, 256, async (kk) => sizeOf(kk)); const k = await findMaxColorsWithin(50, 256, async (kk) => sizeOf(kk));
expect(k).toBe(2); expect(k).toBe(2);
}); });
it("encodeSize null трактуется как провал", async () => { it("encodeSize null treated as failure", async () => {
const k = await findMaxColorsWithin(999999, 16, async () => null); const k = await findMaxColorsWithin(999999, 16, async () => null);
expect(k).toBe(2); expect(k).toBe(2);
}); });
it("бинарный поиск сходится быстрее полного перебора", async () => { it("binary search converges faster than full scan", async () => {
let calls = 0; let calls = 0;
await findMaxColorsWithin(30000, 256, async (kk) => { await findMaxColorsWithin(30000, 256, async (kk) => {
calls++; calls++;
@@ -38,3 +42,38 @@ describe("findMaxColorsWithin", () => {
expect(calls).toBeLessThanOrEqual(12); // log2(254)+probe(2) expect(calls).toBeLessThanOrEqual(12); // log2(254)+probe(2)
}); });
}); });
describe("findQualityWithin", () => {
const sizeOf = (q: number) => 500 + q * 50; // size grows with quality
it("picks max quality fitting the target", async () => {
// target 5000 -> q<=90 fits
const q = await findQualityWithin(5000, async (qq) => sizeOf(qq));
expect(q).toBe(90);
expect(sizeOf(q)).toBeLessThanOrEqual(5000);
});
it("everything fits - returns max quality", async () => {
const q = await findQualityWithin(999999, async (qq) => sizeOf(qq));
expect(q).toBe(100);
});
it("target unreachable even at minimum - returns min", async () => {
const q = await findQualityWithin(10, async (qq) => sizeOf(qq));
expect(q).toBe(1);
});
it("encodeSize null treated as failure", async () => {
const q = await findQualityWithin(999999, async () => null);
expect(q).toBe(1);
});
it("binary search converges faster than full scan", async () => {
let calls = 0;
await findQualityWithin(3000, async (qq) => {
calls++;
return sizeOf(qq);
});
expect(calls).toBeLessThanOrEqual(10); // log2(99)+2 probes
});
});
+40 -4
View File
@@ -1,4 +1,4 @@
/** Пресеты сжатия: уровень → число цветов квантования. */ /** Compression presets: level -> number of quantization colors. */
export const COMPRESSION_LEVELS = { export const COMPRESSION_LEVELS = {
light: 192, light: 192,
balanced: 96, balanced: 96,
@@ -9,9 +9,9 @@ export const COMPRESSION_LEVELS = {
export type CompressionLevel = keyof typeof COMPRESSION_LEVELS; export type CompressionLevel = keyof typeof COMPRESSION_LEVELS;
/** /**
* Бинарный поиск наибольшего k ∈ [2..maxK], при котором закодированный размер * Binary search for the largest k in [2..maxK] whose encoded size fits
* укладывается в targetBytes. Если даже k=2 не влезает — возвращается 2 (best effort). * targetBytes. If even k=2 does not fit, returns 2 (best effort).
* encodeSize может вернуть null (ошибка кодирования) — трактуется как «не влезло». * encodeSize may return null (encode error), treated as "does not fit".
*/ */
export async function findMaxColorsWithin( export async function findMaxColorsWithin(
targetBytes: number, targetBytes: number,
@@ -44,3 +44,39 @@ export async function findMaxColorsWithin(
void fitsAtTwo; void fitsAtTwo;
return ok; return ok;
} }
/**
* Binary search for the highest quality percent in [min..max] whose encoded
* size fits targetBytes. If even min does not fit, returns min (best effort).
* encodeSize may return null (encode error), treated as "does not fit".
*/
export async function findQualityWithin(
targetBytes: number,
encodeSize: (quality: number) => Promise<number | null>,
min = 1,
max = 100,
): Promise<number> {
const lo = Math.max(1, Math.round(min));
const hi = Math.max(lo, Math.round(max));
let ok = lo;
const probe = async (q: number): Promise<boolean> => {
const size = await encodeSize(Math.max(lo, Math.min(hi, q)));
if (size === null) return false;
if (size <= targetBytes) {
ok = q;
return true;
}
return false;
};
if (!(await probe(lo))) return lo;
if (await probe(hi)) return hi;
let a = lo;
let b = hi;
while (b - a > 1) {
const mid = Math.floor((a + b) / 2);
if (await probe(mid)) a = mid;
else b = mid;
}
return ok;
}
+7 -7
View File
@@ -5,7 +5,7 @@ import { makeImage } from "./test-helpers";
const SHARPEN_KERNEL = [0, -1, 0, -1, 5, -1, 0, -1, 0]; const SHARPEN_KERNEL = [0, -1, 0, -1, 5, -1, 0, -1, 0];
describe("convolve", () => { describe("convolve", () => {
it("крестовое ядро резкости на полоске из трёх пикселей", () => { it("cross sharpen kernel on three-pixel strip", () => {
const out = convolve( const out = convolve(
makeImage(3, 1, [ makeImage(3, 1, [
[0, 0, 0, 255], [0, 0, 0, 255],
@@ -24,7 +24,7 @@ describe("convolve", () => {
[2, 3], [2, 3],
[3.5, 3], [3.5, 3],
[0, 3], [0, 3],
])("бросает ошибку на некорректном размере ядра %i", (size) => { ])("throws on invalid kernel size %i", (size) => {
expect(() => expect(() =>
convolve(makeImage(1, 1, [[0, 0, 0, 255]]), [1], size as number), convolve(makeImage(1, 1, [[0, 0, 0, 255]]), [1], size as number),
).toThrow(); ).toThrow();
@@ -32,7 +32,7 @@ describe("convolve", () => {
}); });
describe("sharpen", () => { describe("sharpen", () => {
it("сила 0 возвращает копию", () => { it("strength 0 returns copy", () => {
const img = makeImage(2, 2, [ const img = makeImage(2, 2, [
[10, 20, 30, 255], [10, 20, 30, 255],
[40, 50, 60, 128], [40, 50, 60, 128],
@@ -42,7 +42,7 @@ describe("sharpen", () => {
expect([...sharpen(img, 0).data]).toEqual([...img.data]); expect([...sharpen(img, 0).data]).toEqual([...img.data]);
}); });
it("сила 100 применяет чистое ядро резкости", () => { it("strength 100 applies pure sharpen kernel", () => {
const out = sharpen( const out = sharpen(
makeImage(3, 1, [ makeImage(3, 1, [
[0, 0, 0, 255], [0, 0, 0, 255],
@@ -57,7 +57,7 @@ describe("sharpen", () => {
}); });
describe("gaussianBlur", () => { describe("gaussianBlur", () => {
it("постоянное изображение не меняется ни в RGB, ни в альфе", () => { it("constant image unchanged in RGB and alpha", () => {
const img = makeImage(3, 3, new Array(9).fill([40, 80, 120, 128])); const img = makeImage(3, 3, new Array(9).fill([40, 80, 120, 128]));
const out = gaussianBlur(img, 16); const out = gaussianBlur(img, 16);
for (let i = 0; i < out.data.length; i++) { for (let i = 0; i < out.data.length; i++) {
@@ -65,7 +65,7 @@ describe("gaussianBlur", () => {
} }
}); });
it("далёкие углы остаются прозрачными, цвет центра не искажается", () => { it("far corners stay transparent, center color undistorted", () => {
const size = 61; const size = 61;
const pixels: number[][] = []; const pixels: number[][] = [];
for (let y = 0; y < size; y++) { for (let y = 0; y < size; y++) {
@@ -89,7 +89,7 @@ describe("gaussianBlur", () => {
expect(out.data[center + 2]).toBe(25); expect(out.data[center + 2]).toBe(25);
}); });
it("симметричный вход даёт симметричный результат", () => { it("symmetric input yields symmetric result", () => {
const leftByRow = [ const leftByRow = [
[ [
[255, 0, 0, 255], [255, 0, 0, 255],
+5 -5
View File
@@ -4,16 +4,16 @@ import { formatStamp } from "./datefmt";
const d = new Date(2026, 7, 25, 9, 5, 3); const d = new Date(2026, 7, 25, 9, 5, 3);
describe("formatStamp", () => { describe("formatStamp", () => {
it("разворачивает все базовые токены", () => { it("expands all base tokens", () => {
expect(formatStamp(d, "YYYY-MM-DD hh:mm:ss")).toBe("2026-08-25 09:05:03"); expect(formatStamp(d, "YYYY-MM-DD hh:mm:ss")).toBe("2026-08-25 09:05:03");
}); });
it("произвольный текст между токенами сохраняется", () => { it("arbitrary text between tokens is preserved", () => {
expect(formatStamp(d, "DD.MM.YYYY")).toBe("25.08.2026"); expect(formatStamp(d, "DD.MM.YYYY")).toBe("25.08.2026");
expect(formatStamp(d, "YYYY год, MM месяц")).toBe("2026 год, 08 месяц"); expect(formatStamp(d, "YYYY year, MM month")).toBe("2026 year, 08 month");
}); });
it("неизвестные последовательности не трогаются", () => { it("unknown sequences are left alone", () => {
expect(formatStamp(d, "YYYYYY MMМ")).toBe("2026YY 08М"); expect(formatStamp(d, "YYYYYY MMX")).toBe("2026YY 08X");
}); });
}); });
+2 -2
View File
@@ -1,8 +1,8 @@
const PAD2 = (n: number) => String(n).padStart(2, "0"); const PAD2 = (n: number) => String(n).padStart(2, "0");
/** /**
* Мини-форматтер штампа даты: токены YYYY MM DD hh mm ss заменяются * Mini date-stamp formatter: tokens YYYY MM DD hh mm ss are replaced with
* значениями локального времени, остальные символы остаются как есть. * local time values, other characters are kept as-is.
*/ */
export function formatStamp(date: Date, pattern: string): string { export function formatStamp(date: Date, pattern: string): string {
return pattern.replace(/YYYY|MM|DD|hh|mm|ss/g, (token) => { return pattern.replace(/YYYY|MM|DD|hh|mm|ss/g, (token) => {
+5 -5
View File
@@ -56,7 +56,7 @@ export interface TextBlockOptions {
angleDeg?: number; angleDeg?: number;
} }
/** Одна надпись (с автопереносом и опциональной плашкой) поверх изображения. */ /** One caption (auto-wrapped, optional backing plate) over the image. */
export function drawTextBlock( export function drawTextBlock(
img: PixelImage, img: PixelImage,
o: TextBlockOptions, o: TextBlockOptions,
@@ -132,7 +132,7 @@ export interface TextToImageOptions {
maxTextWidth?: number; maxTextWidth?: number;
} }
/** Картинка из текста: холст подгоняется под размер надписи с паддингом. */ /** Image from text: canvas sized to the caption plus padding. */
export function renderTextToImage(o: TextToImageOptions): PixelImage { export function renderTextToImage(o: TextToImageOptions): PixelImage {
const measure = ctx2d(8, 8).ctx; const measure = ctx2d(8, 8).ctx;
measure.font = fontString(o.fontSize, o.font, o.bold); measure.font = fontString(o.fontSize, o.font, o.bold);
@@ -161,7 +161,7 @@ export function renderTextToImage(o: TextToImageOptions): PixelImage {
return toPixelImage(canvas); return toPixelImage(canvas);
} }
/** Эмодзи/символ как PNG: рисуется платформенным шрифтом по центру. */ /** Emoji/symbol as PNG: drawn centered with the platform emoji font. */
export function renderEmoji(symbol: string, size: number): PixelImage { export function renderEmoji(symbol: string, size: number): PixelImage {
const { canvas, ctx } = ctx2d(size, size); const { canvas, ctx } = ctx2d(size, size);
ctx.font = `${Math.round(size * 0.72)}px "Apple Color Emoji","Segoe UI Emoji","Noto Color Emoji",sans-serif`; ctx.font = `${Math.round(size * 0.72)}px "Apple Color Emoji","Segoe UI Emoji","Noto Color Emoji",sans-serif`;
@@ -179,7 +179,7 @@ export interface ImageWatermarkOptions {
margin: number; margin: number;
} }
/** Картинка-знак поверх изображения: масштаб от ширины холста, позиция 3×3. */ /** Image mark over the image: scale relative to canvas width, 3x3 position. */
export function drawImageWatermark( export function drawImageWatermark(
img: PixelImage, img: PixelImage,
o: ImageWatermarkOptions, o: ImageWatermarkOptions,
@@ -221,7 +221,7 @@ export function drawImageWatermark(
return toPixelImage(canvas); return toPixelImage(canvas);
} }
/** Повторяющаяся диагональная плитка текста на весь холст. */ /** Repeating diagonal text tile across the whole canvas. */
export function drawTextTile(img: PixelImage, o: TileTextOptions): PixelImage { export function drawTextTile(img: PixelImage, o: TileTextOptions): PixelImage {
const { canvas, ctx } = ctx2d(img.width, img.height); const { canvas, ctx } = ctx2d(img.width, img.height);
ctx.putImageData( ctx.putImageData(
+19 -3
View File
@@ -1,14 +1,14 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { vignette } from "./effects"; import { vignette, vignetteMask } from "./effects";
import { makeImage } from "./test-helpers"; import { makeImage } from "./test-helpers";
describe("vignette", () => { describe("vignette", () => {
it("сила 0 — идентичное преобразование", () => { it("strength 0 - identity transform", () => {
const img = makeImage(3, 3, new Array(9).fill([200, 100, 50, 255])); const img = makeImage(3, 3, new Array(9).fill([200, 100, 50, 255]));
expect([...vignette(img, 0).data]).toEqual([...img.data]); expect([...vignette(img, 0).data]).toEqual([...img.data]);
}); });
it("углы темнее центра", () => { it("corners darker than center", () => {
const img = makeImage(7, 7, new Array(49).fill([200, 200, 200, 255])); const img = makeImage(7, 7, new Array(49).fill([200, 200, 200, 255]));
const out = vignette(img, 80); const out = vignette(img, 80);
const centerR = out.data[(3 * 7 + 3) * 4]; const centerR = out.data[(3 * 7 + 3) * 4];
@@ -17,3 +17,19 @@ describe("vignette", () => {
expect(centerR).toBeGreaterThan(150); expect(centerR).toBeGreaterThan(150);
}); });
}); });
describe("vignetteMask", () => {
it("white at corners, black at center, opaque", () => {
const out = vignetteMask(7, 7, 80);
const center = out.data[(3 * 7 + 3) * 4];
const corner = out.data[0];
expect(corner).toBeGreaterThan(center);
expect(corner).toBeGreaterThan(100);
expect(out.data[3]).toBe(255);
});
it("zero strength -> all black", () => {
const out = vignetteMask(3, 3, 0);
expect(Math.max(...out.data.filter((_, i) => i % 4 !== 3))).toBe(0);
});
});
+27
View File
@@ -22,3 +22,30 @@ export function vignette(img: PixelImage, strengthPercent: number): PixelImage {
} }
return out; return out;
} }
/** B/w preview of the vignette falloff: white where darkening is strongest. */
export function vignetteMask(
width: number,
height: number,
strengthPercent: number,
): PixelImage {
const strength = Math.min(Math.max(strengthPercent, 0), 100) / 100;
const cx = (width - 1) / 2;
const cy = (height - 1) / 2;
const maxDist = Math.sqrt(cx * cx + cy * cy) || 1;
const out = createPixelImage(width, height);
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
const dx = x - cx;
const dy = y - cy;
const t = Math.sqrt(dx * dx + dy * dy) / maxDist;
const v = Math.round(strength * t * t * 255);
const di = (y * width + x) * 4;
out.data[di] = v;
out.data[di + 1] = v;
out.data[di + 2] = v;
out.data[di + 3] = 255;
}
}
return out;
}
+3 -3
View File
@@ -1,9 +1,9 @@
export type ErrorVars = Record<string, string | number>; export type ErrorVars = Record<string, string | number>;
/** /**
* Ошибка с стабильным ключом перевода вместо готового текста. * Error with a stable translation key instead of ready-made text.
* Ядро бросает только её; человекочитаемый текст подставляет слой UI * Core throws only this; the UI layer supplies human-readable text
* по секции errors активной локали. * from the errors section of the active locale.
*/ */
export class ToolError extends Error { export class ToolError extends Error {
readonly key: string; readonly key: string;
+18 -18
View File
@@ -11,7 +11,7 @@ import {
import { makeImage } from "./test-helpers"; import { makeImage } from "./test-helpers";
const bordered = () => { const bordered = () => {
// 4×4: рамка из прозрачных пикселей вокруг красного центра 2×2 // 4x4: transparent pixel frame around red 2x2 center
const img = makeImage(4, 4, new Array(16).fill([0, 0, 0, 0])); const img = makeImage(4, 4, new Array(16).fill([0, 0, 0, 0]));
for (let y = 1; y <= 2; y++) { for (let y = 1; y <= 2; y++) {
for (let x = 1; x <= 2; x++) { for (let x = 1; x <= 2; x++) {
@@ -24,11 +24,11 @@ const bordered = () => {
}; };
describe("contentBounds / trimToContent", () => { describe("contentBounds / trimToContent", () => {
it("границы по порогу альфы", () => { it("bounds by alpha threshold", () => {
expect(contentBounds(bordered(), 0)).toEqual({ x: 1, y: 1, w: 2, h: 2 }); expect(contentBounds(bordered(), 0)).toEqual({ x: 1, y: 1, w: 2, h: 2 });
}); });
it("trim обрезает поля и сохраняет содержимое", () => { it("trim cuts margins and keeps content", () => {
const out = trimToContent(bordered(), 0); const out = trimToContent(bordered(), 0);
expect(out.width).toBe(2); expect(out.width).toBe(2);
expect(out.height).toBe(2); expect(out.height).toBe(2);
@@ -36,7 +36,7 @@ describe("contentBounds / trimToContent", () => {
expect(out.data[3]).toBe(255); expect(out.data[3]).toBe(255);
}); });
it("полностью прозрачное изображение → 1×1", () => { it("fully transparent image -> 1x1", () => {
const empty = makeImage(3, 3, new Array(9).fill([0, 0, 0, 0])); const empty = makeImage(3, 3, new Array(9).fill([0, 0, 0, 0]));
const out = trimToContent(empty, 0); const out = trimToContent(empty, 0);
expect(out.width).toBe(1); expect(out.width).toBe(1);
@@ -52,35 +52,35 @@ describe("changeCanvasSize", () => {
[4, 4, 4, 255], [4, 4, 4, 255],
]); ]);
it("увеличение с якорем center — прозрачные поля со всех сторон", () => { it("grow with center anchor - transparent margins all around", () => {
const out = changeCanvasSize(img, 4, 4, "center"); const out = changeCanvasSize(img, 4, 4, "center");
expect(out.width).toBe(4); expect(out.width).toBe(4);
expect(out.data[3]).toBe(0); expect(out.data[3]).toBe(0);
expect(out.data[(1 * 4 + 1) * 4 + 3]).toBe(255); expect(out.data[(1 * 4 + 1) * 4 + 3]).toBe(255);
}); });
it("увеличение с якорем top-left — контент прижат в угол", () => { it("grow with top-left anchor - content pinned to corner", () => {
const out = changeCanvasSize(img, 4, 4, "top-left"); const out = changeCanvasSize(img, 4, 4, "top-left");
expect(out.data[3]).toBe(255); expect(out.data[3]).toBe(255);
expect(out.data[(3 * 4 + 3) * 4 + 3]).toBe(0); expect(out.data[(3 * 4 + 3) * 4 + 3]).toBe(0);
}); });
it("уменьшение обрезает как кроп от якоря bottom-right", () => { it("shrink crops from bottom-right anchor", () => {
const out = changeCanvasSize(img, 1, 1, "bottom-right"); const out = changeCanvasSize(img, 1, 1, "bottom-right");
expect(out.data[0]).toBe(4); expect(out.data[0]).toBe(4);
}); });
}); });
describe("соотношение сторон", () => { describe("aspect ratio", () => {
const wide = makeImage(400, 200, new Array(80000).fill([9, 9, 9, 255])); const wide = makeImage(400, 200, new Array(80000).fill([9, 9, 9, 255]));
it("cropToRatio 1:1 из 2:1 → квадрат по высоте", () => { it("cropToRatio 1:1 from 2:1 -> square by height", () => {
const out = cropToRatio(wide, 1); const out = cropToRatio(wide, 1);
expect(out.width).toBe(200); expect(out.width).toBe(200);
expect(out.height).toBe(200); expect(out.height).toBe(200);
}); });
it("padToRatio 1:1 из 2:1 → квадрат с прозрачными полями", () => { it("padToRatio 1:1 from 2:1 -> square with transparent margins", () => {
const out = padToRatio(wide, 1); const out = padToRatio(wide, 1);
expect(out.width).toBe(400); expect(out.width).toBe(400);
expect(out.height).toBe(400); expect(out.height).toBe(400);
@@ -90,21 +90,21 @@ describe("соотношение сторон", () => {
}); });
describe("forceOrientation / symmetricCopy", () => { describe("forceOrientation / symmetricCopy", () => {
it("широкое становится высоким поворотом", () => { it("wide becomes tall via rotation", () => {
const wide = makeImage(300, 100, new Array(30000).fill([5, 5, 5, 255])); const wide = makeImage(300, 100, new Array(30000).fill([5, 5, 5, 255]));
const out = forceOrientation(wide, "portrait"); const out = forceOrientation(wide, "portrait");
expect(out.width).toBe(100); expect(out.width).toBe(100);
expect(out.height).toBe(300); expect(out.height).toBe(300);
}); });
it("квадрат не поворачивается", () => { it("square is not rotated", () => {
const sq = makeImage(50, 50, new Array(2500).fill([5, 5, 5, 255])); const sq = makeImage(50, 50, new Array(2500).fill([5, 5, 5, 255]));
const out = forceOrientation(sq, "portrait"); const out = forceOrientation(sq, "portrait");
expect(out.width).toBe(50); expect(out.width).toBe(50);
expect(out.height).toBe(50); expect(out.height).toBe(50);
}); });
it("симметричная копия удваивает ширину и зеркалит правую половину", () => { it("symmetric copy doubles width and mirrors right half", () => {
const img = makeImage(2, 1, [ const img = makeImage(2, 1, [
[10, 0, 0, 255], [10, 0, 0, 255],
[20, 0, 0, 255], [20, 0, 0, 255],
@@ -112,18 +112,18 @@ describe("forceOrientation / symmetricCopy", () => {
const out = symmetricCopy(img, "vertical", "left"); const out = symmetricCopy(img, "vertical", "left");
expect(out.width).toBe(4); expect(out.width).toBe(4);
expect(out.data[0]).toBe(10); expect(out.data[0]).toBe(10);
expect(out.data[8]).toBe(20); // пиксель 2 = зеркало начала expect(out.data[8]).toBe(20); // pixel 2 = mirror of start
expect(out.data[12]).toBe(10); // пиксель 3 = зеркало конца expect(out.data[12]).toBe(10); // pixel 3 = mirror of end
}); });
it("вертикальная ось удваивает высоту", () => { it("vertical axis doubles height", () => {
const img = makeImage(1, 2, [ const img = makeImage(1, 2, [
[10, 0, 0, 255], [10, 0, 0, 255],
[30, 0, 0, 255], [30, 0, 0, 255],
]); ]);
const out = symmetricCopy(img, "horizontal", "top"); const out = symmetricCopy(img, "horizontal", "top");
expect(out.height).toBe(4); expect(out.height).toBe(4);
expect(out.data[(2 * 1 + 0) * 4]).toBe(30); // строка 2 = зеркало строки 1 expect(out.data[(2 * 1 + 0) * 4]).toBe(30); // row 2 = mirror of row 1
expect(out.data[(3 * 1 + 0) * 4]).toBe(10); // строка 3 = зеркало строки 0 expect(out.data[(3 * 1 + 0) * 4]).toBe(10); // row 3 = mirror of row 0
}); });
}); });
+8 -8
View File
@@ -2,13 +2,13 @@ import { describe, expect, it } from "vitest";
import { colorSpectrum, drawGrid, randomColorBlocks } from "./gen-tools"; import { colorSpectrum, drawGrid, randomColorBlocks } from "./gen-tools";
describe("colorSpectrum", () => { describe("colorSpectrum", () => {
it("горизонтальный: левый край красный (hue 0)", () => { it("horizontal: left edge red (hue 0)", () => {
const img = colorSpectrum(100, 10, "horizontal", 100, 50); const img = colorSpectrum(100, 10, "horizontal", 100, 50);
expect(img.data[0]).toBeGreaterThan(200); expect(img.data[0]).toBeGreaterThan(200);
expect(img.data[1]).toBeLessThan(60); expect(img.data[1]).toBeLessThan(60);
}); });
it("вертикальный: зелёный максимум около hue 120°", () => { it("vertical: green max near hue 120 deg", () => {
const img = colorSpectrum(10, 100, "vertical", 100, 50); const img = colorSpectrum(10, 100, "vertical", 100, 50);
const rowHue120 = Math.round((120 / 360) * 99); const rowHue120 = Math.round((120 / 360) * 99);
const g = img.data[(rowHue120 * 10 + 5) * 4 + 1]; const g = img.data[(rowHue120 * 10 + 5) * 4 + 1];
@@ -17,7 +17,7 @@ describe("colorSpectrum", () => {
}); });
describe("randomColorBlocks", () => { describe("randomColorBlocks", () => {
it("детерминирован по seed и блоки однотонные", () => { it("deterministic by seed, blocks are solid", () => {
const a = randomColorBlocks(64, 64, 16, 7); const a = randomColorBlocks(64, 64, 16, 7);
const b = randomColorBlocks(64, 64, 16, 7); const b = randomColorBlocks(64, 64, 16, 7);
expect([...a.data]).toEqual([...b.data]); expect([...a.data]).toEqual([...b.data]);
@@ -28,21 +28,21 @@ describe("randomColorBlocks", () => {
}); });
describe("drawGrid", () => { describe("drawGrid", () => {
it("линии на пересечениях непрозрачны, фон прозрачен", () => { it("grid lines opaque, background transparent", () => {
const out = drawGrid(100, 100, 4, 4, 2, "#000000", 0); const out = drawGrid(100, 100, 4, 4, 2, "#000000", 0);
expect(out.data[0]).toBe(0); expect(out.data[0]).toBe(0);
expect(out.data[3]).toBe(255); // (0,0) на линии expect(out.data[3]).toBe(255); // (0,0) on a line
const mid = (53 * 100 + 53) * 4; const mid = (53 * 100 + 53) * 4;
expect(out.data[mid + 3]).toBe(0); // между линиями прозрачн expect(out.data[mid + 3]).toBe(0); // transparent between lines
}); });
it("белый непрозрачный фон при bgOpacity=100", () => { it("white opaque background at bgOpacity=100", () => {
const out = drawGrid(20, 20, 2, 2, 1, "#000000", 100); const out = drawGrid(20, 20, 2, 2, 1, "#000000", 100);
const mid = (5 * 20 + 5) * 4; const mid = (5 * 20 + 5) * 4;
expect(out.data[mid]).toBe(255); expect(out.data[mid]).toBe(255);
}); });
it("полупрозрачный фон при bgOpacity=50", () => { it("semi-transparent background at bgOpacity=50", () => {
const out = drawGrid(20, 20, 2, 2, 1, "#000000", 50); const out = drawGrid(20, 20, 2, 2, 1, "#000000", 50);
const mid = (5 * 20 + 5) * 4; const mid = (5 * 20 + 5) * 4;
expect(out.data[mid + 3]).toBe(128); expect(out.data[mid + 3]).toBe(128);
+3 -3
View File
@@ -2,7 +2,7 @@ import { createPixelImage, type PixelImage } from "./types";
import { hslToRgb } from "./palette"; import { hslToRgb } from "./palette";
import { mulberry32 } from "./pixel-fx"; import { mulberry32 } from "./pixel-fx";
/** Радужный спектр: оттенок 0..360 вдоль выбранной оси. */ /** Rainbow spectrum: hue 0..360 along the chosen axis. */
export function colorSpectrum( export function colorSpectrum(
width: number, width: number,
height: number, height: number,
@@ -32,7 +32,7 @@ export function colorSpectrum(
return out; return out;
} }
/** Случайные яркие блоки: детерминировано по seed. */ /** Random vivid blocks: deterministic per seed. */
export function randomColorBlocks( export function randomColorBlocks(
width: number, width: number,
height: number, height: number,
@@ -79,7 +79,7 @@ function lineMask(
return mask; return mask;
} }
/** Сетка линий на белом фоне заданной непрозрачности (0 — прозрачный). */ /** Line grid on a white background of given opacity (0 = transparent). */
export function drawGrid( export function drawGrid(
width: number, width: number,
height: number, height: number,
+7 -7
View File
@@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest";
import { gradientImage, noiseImage, solidImage } from "./generate"; import { gradientImage, noiseImage, solidImage } from "./generate";
describe("solidImage", () => { describe("solidImage", () => {
it("заливает весь холст заданным цветом", () => { it("fills whole canvas with given color", () => {
const out = solidImage(2, 2, [10, 20, 30, 255]); const out = solidImage(2, 2, [10, 20, 30, 255]);
expect(out.width).toBe(2); expect(out.width).toBe(2);
expect([...out.data]).toEqual(new Array(4).fill([10, 20, 30, 255]).flat()); expect([...out.data]).toEqual(new Array(4).fill([10, 20, 30, 255]).flat());
@@ -13,25 +13,25 @@ describe("solidImage", () => {
[10, 0], [10, 0],
[2.5, 10], [2.5, 10],
[-1, 5], [-1, 5],
])("бросает ошибку на размерах %i x %i", (w, h) => { ])("throws on sizes %i x %i", (w, h) => {
expect(() => solidImage(w, h, [0, 0, 0, 255])).toThrow(); expect(() => solidImage(w, h, [0, 0, 0, 255])).toThrow();
}); });
}); });
describe("noiseImage", () => { describe("noiseImage", () => {
it("детерминирован: одно зерно — одни байты", () => { it("deterministic: same seed - same bytes", () => {
expect([...noiseImage(4, 4, 42).data]).toEqual([ expect([...noiseImage(4, 4, 42).data]).toEqual([
...noiseImage(4, 4, 42).data, ...noiseImage(4, 4, 42).data,
]); ]);
}); });
it("разные зерна дают разные данные", () => { it("different seeds give different data", () => {
const a = [...noiseImage(8, 8, 1).data]; const a = [...noiseImage(8, 8, 1).data];
const b = [...noiseImage(8, 8, 2).data]; const b = [...noiseImage(8, 8, 2).data];
expect(a).not.toEqual(b); expect(a).not.toEqual(b);
}); });
it("альфа всегда непрозрачная", () => { it("alpha always opaque", () => {
const data = noiseImage(3, 3, 7).data; const data = noiseImage(3, 3, 7).data;
for (let i = 3; i < data.length; i += 4) { for (let i = 3; i < data.length; i += 4) {
expect(data[i]).toBe(255); expect(data[i]).toBe(255);
@@ -40,7 +40,7 @@ describe("noiseImage", () => {
}); });
describe("gradientImage", () => { describe("gradientImage", () => {
it("горизонтальный градиент идёт от цвета A к цвету B", () => { it("horizontal gradient goes from color A to color B", () => {
const out = gradientImage( const out = gradientImage(
3, 3,
1, 1,
@@ -54,7 +54,7 @@ describe("gradientImage", () => {
expect(px(2)).toEqual([255, 255, 255, 255]); expect(px(2)).toEqual([255, 255, 255, 255]);
}); });
it("вертикальный градиент меняется по строкам", () => { it("vertical gradient changes by rows", () => {
const out = gradientImage( const out = gradientImage(
1, 1,
2, 2,
+26 -26
View File
@@ -22,7 +22,7 @@ const square = () =>
]); ]);
describe("flip", () => { describe("flip", () => {
it("отражает по горизонтали (зеркало слева-направо)", () => { it("flips horizontally (left-right mirror)", () => {
const out = flip(square(), "horizontal"); const out = flip(square(), "horizontal");
expect(out.width).toBe(2); expect(out.width).toBe(2);
expect(out.height).toBe(2); expect(out.height).toBe(2);
@@ -31,14 +31,14 @@ describe("flip", () => {
]); ]);
}); });
it("отражает по вертикали (сверху-вниз)", () => { it("flips vertically (top-bottom)", () => {
const out = flip(square(), "vertical"); const out = flip(square(), "vertical");
expect([...out.data]).toEqual([ expect([...out.data]).toEqual([
3, 3, 3, 3, 4, 4, 4, 4, 1, 1, 1, 1, 2, 2, 2, 2, 3, 3, 3, 3, 4, 4, 4, 4, 1, 1, 1, 1, 2, 2, 2, 2,
]); ]);
}); });
it("не мутирует вход", () => { it("does not mutate input", () => {
const img = square(); const img = square();
flip(img, "horizontal"); flip(img, "horizontal");
expect([...img.data]).toEqual([ expect([...img.data]).toEqual([
@@ -48,7 +48,7 @@ describe("flip", () => {
}); });
describe("rotate90", () => { describe("rotate90", () => {
it("поворачивает неквадрат 2x3 на 90° по часовой", () => { it("rotates non-square 2x3 by 90 deg clockwise", () => {
const rect = makeImage(2, 3, [ const rect = makeImage(2, 3, [
[1, 1, 1, 1], [1, 1, 1, 1],
[2, 2, 2, 2], [2, 2, 2, 2],
@@ -65,21 +65,21 @@ describe("rotate90", () => {
]); ]);
}); });
it("turns=0 возвращает копию без изменений", () => { it("turns=0 returns unchanged copy", () => {
const img = square(); const img = square();
const out = rotate90(img, 0); const out = rotate90(img, 0);
expect(out).not.toBe(img); expect(out).not.toBe(img);
expect([...out.data]).toEqual([...img.data]); expect([...out.data]).toEqual([...img.data]);
}); });
it("нормализует turns: 5 ≡ 1, -3 ≡ 1, 4 ≡ 0", () => { it("normalizes turns: 5 == 1, -3 == 1, 4 == 0", () => {
const once = [...rotate90(square(), 1).data]; const once = [...rotate90(square(), 1).data];
expect([...rotate90(square(), 5).data]).toEqual(once); expect([...rotate90(square(), 5).data]).toEqual(once);
expect([...rotate90(square(), -3).data]).toEqual(once); expect([...rotate90(square(), -3).data]).toEqual(once);
expect([...rotate90(square(), 4).data]).toEqual([...square().data]); expect([...rotate90(square(), 4).data]).toEqual([...square().data]);
}); });
it("turns=2 на квадрате равно двойному отражению", () => { it("turns=2 on square equals double flip", () => {
const out = rotate90(square(), 2); const out = rotate90(square(), 2);
expect([...out.data]).toEqual([ expect([...out.data]).toEqual([
4, 4, 4, 4, 3, 3, 3, 3, 2, 2, 2, 2, 1, 1, 1, 1, 4, 4, 4, 4, 3, 3, 3, 3, 2, 2, 2, 2, 1, 1, 1, 1,
@@ -101,7 +101,7 @@ describe("crop", () => {
[9, 9, 9, 9], [9, 9, 9, 9],
]); ]);
it("вырезает центральную область 2x2 из 3x3", () => { it("cuts central 2x2 area from 3x3", () => {
const out = crop(grid(), 1, 1, 2, 2); const out = crop(grid(), 1, 1, 2, 2);
expect(out.width).toBe(2); expect(out.width).toBe(2);
expect(out.height).toBe(2); expect(out.height).toBe(2);
@@ -110,14 +110,14 @@ describe("crop", () => {
]); ]);
}); });
it("усекает область, выходящую за границы", () => { it("clamps area exceeding bounds", () => {
const out = crop(grid(), -1, -1, 2, 2); const out = crop(grid(), -1, -1, 2, 2);
expect(out.width).toBe(1); expect(out.width).toBe(1);
expect(out.height).toBe(1); expect(out.height).toBe(1);
expect([...out.data]).toEqual([1, 1, 1, 1]); expect([...out.data]).toEqual([1, 1, 1, 1]);
}); });
it("бросает ToolError для области вне изображения", () => { it("throws ToolError for area outside image", () => {
expect(() => crop(grid(), 5, 5, 2, 2)).toThrow(/errors\.cropBounds/); expect(() => crop(grid(), 5, 5, 2, 2)).toThrow(/errors\.cropBounds/);
}); });
}); });
@@ -125,7 +125,7 @@ describe("crop", () => {
describe("expandCanvas", () => { describe("expandCanvas", () => {
const pixel = () => makeImage(1, 1, [[10, 20, 30, 255]]); const pixel = () => makeImage(1, 1, [[10, 20, 30, 255]]);
it("прозрачное расширение кладёт пиксель со смещением", () => { it("transparent expansion places pixel with offset", () => {
const out = expandCanvas(pixel(), 1, 2, 3, 4); const out = expandCanvas(pixel(), 1, 2, 3, 4);
expect(out.width).toBe(5); expect(out.width).toBe(5);
expect(out.height).toBe(7); expect(out.height).toBe(7);
@@ -135,7 +135,7 @@ describe("expandCanvas", () => {
]); ]);
}); });
it("цветной фон заливает всё вокруг", () => { it("colored background fills surroundings", () => {
const out = expandCanvas(pixel(), 1, 0, 0, 0, "#ffffff"); const out = expandCanvas(pixel(), 1, 0, 0, 0, "#ffffff");
expect(out.data[0]).toBe(255); expect(out.data[0]).toBe(255);
expect(out.data[3]).toBe(255); expect(out.data[3]).toBe(255);
@@ -144,7 +144,7 @@ describe("expandCanvas", () => {
}); });
describe("tile", () => { describe("tile", () => {
it("повторяет изображение по сетке", () => { it("repeats image on grid", () => {
const out = tile(makeImage(1, 1, [[9, 9, 9, 255]]), 3, 2); const out = tile(makeImage(1, 1, [[9, 9, 9, 255]]), 3, 2);
expect(out.width).toBe(3); expect(out.width).toBe(3);
expect(out.height).toBe(2); expect(out.height).toBe(2);
@@ -155,7 +155,7 @@ describe("tile", () => {
}); });
describe("centerByAlpha", () => { describe("centerByAlpha", () => {
it("вырезает непрозрачный блок и центрирует на прежнем холсте", () => { it("cuts opaque block and centers on same canvas", () => {
const img = makeImage(3, 3, [ const img = makeImage(3, 3, [
[0, 0, 0, 0], [0, 0, 0, 0],
[0, 0, 0, 0], [0, 0, 0, 0],
@@ -175,7 +175,7 @@ describe("centerByAlpha", () => {
expect(alphaAt(1, 1)).toBe(255); expect(alphaAt(1, 1)).toBe(255);
}); });
it("полностью прозрачное изображение возвращается без изменений", () => { it("fully transparent image returned unchanged", () => {
const img = makeImage(2, 1, [ const img = makeImage(2, 1, [
[0, 0, 0, 0], [0, 0, 0, 0],
[0, 0, 0, 0], [0, 0, 0, 0],
@@ -193,12 +193,12 @@ describe("resize", () => {
[255, 255, 255, 255], [255, 255, 255, 255],
]); ]);
it("совпадает с входом при тех же размерах", () => { it("matches input at same size", () => {
const img = twoByTwo(); const img = twoByTwo();
expect([...resize(img, 2, 2).data]).toEqual([...img.data]); expect([...resize(img, 2, 2).data]).toEqual([...img.data]);
}); });
it("апскейл 2x2 -> 4x4 билинейно интерполирует", () => { it("upscale 2x2 -> 4x4 bilinear interpolation", () => {
const out = resize(twoByTwo(), 4, 4); const out = resize(twoByTwo(), 4, 4);
expect(out.width).toBe(4); expect(out.width).toBe(4);
expect(out.height).toBe(4); expect(out.height).toBe(4);
@@ -207,7 +207,7 @@ describe("resize", () => {
expect(red.slice(4, 8)).toEqual([50, 72, 117, 139]); expect(red.slice(4, 8)).toEqual([50, 72, 117, 139]);
}); });
it("бросает ToolError на некорректные размеры", () => { it("throws ToolError on invalid sizes", () => {
const img = twoByTwo(); const img = twoByTwo();
expect(() => resize(img, 0, 10)).toThrow(/errors\.sizeInt/); expect(() => resize(img, 0, 10)).toThrow(/errors\.sizeInt/);
expect(() => resize(img, 10.5, 10)).toThrow(/errors\.sizeInt/); expect(() => resize(img, 10.5, 10)).toThrow(/errors\.sizeInt/);
@@ -226,7 +226,7 @@ describe("splitToParts", () => {
return makeImage(w, h, pixels); return makeImage(w, h, pixels);
} }
it("ровное деление 4x4 на 2x2 даёт четыре части 2x2 в row-major порядке", () => { it("even 4x4 split into 2x2 gives four parts in row-major order", () => {
const parts = splitToParts(grid(4, 4), 2, 2); const parts = splitToParts(grid(4, 4), 2, 2);
expect(parts).toHaveLength(4); expect(parts).toHaveLength(4);
for (const part of parts) { for (const part of parts) {
@@ -259,7 +259,7 @@ describe("splitToParts", () => {
]); ]);
}); });
it("неделимый размер: канвас дополняется прозрачным, части одинаковые", () => { it("indivisible size: canvas padded transparent, parts equal", () => {
const parts = splitToParts(grid(5, 3), 2, 2); const parts = splitToParts(grid(5, 3), 2, 2);
expect(parts).toHaveLength(4); expect(parts).toHaveLength(4);
for (const part of parts) { for (const part of parts) {
@@ -300,7 +300,7 @@ describe("splitToParts", () => {
]); ]);
}); });
it("одна колонка или строка — полосы без паддинга", () => { it("single column or row - strips without padding", () => {
const parts = splitToParts(grid(3, 4), 1, 2); const parts = splitToParts(grid(3, 4), 1, 2);
expect(parts).toHaveLength(2); expect(parts).toHaveLength(2);
expect(parts[0].width).toBe(3); expect(parts[0].width).toBe(3);
@@ -308,7 +308,7 @@ describe("splitToParts", () => {
expect(parts[1].height).toBe(2); expect(parts[1].height).toBe(2);
}); });
it("части в row-major порядке: последний кусок содержит правый нижний пиксель", () => { it("row-major order: last part holds bottom-right pixel", () => {
const img = grid(5, 3); const img = grid(5, 3);
const parts = splitToParts(img, 2, 2); const parts = splitToParts(img, 2, 2);
expect(parts[parts.length - 1].data[0]).toBe(14); expect(parts[parts.length - 1].data[0]).toBe(14);
@@ -316,7 +316,7 @@ describe("splitToParts", () => {
expect(parts[parts.length - 1].data[8]).toBe(0); expect(parts[parts.length - 1].data[8]).toBe(0);
}); });
it("дробные и нулевые значения колонок/строк нормализуются", () => { it("fractional and zero cols/rows are normalized", () => {
const parts = splitToParts(grid(6, 6), 2.9, 0); const parts = splitToParts(grid(6, 6), 2.9, 0);
expect(parts).toHaveLength(2); expect(parts).toHaveLength(2);
expect(parts[0].width).toBe(3); expect(parts[0].width).toBe(3);
@@ -336,7 +336,7 @@ describe("splitToTileSize", () => {
return makeImage(w, h, pixels); return makeImage(w, h, pixels);
} }
it("кратный размер: части ровно tileW×tileH без паддинга", () => { it("multiple size: parts exactly tileW x tileH without padding", () => {
const parts = splitToTileSize(grid(4, 4), 2, 2); const parts = splitToTileSize(grid(4, 4), 2, 2);
expect(parts).toHaveLength(4); expect(parts).toHaveLength(4);
for (const part of parts) { for (const part of parts) {
@@ -351,7 +351,7 @@ describe("splitToTileSize", () => {
]); ]);
}); });
it("некратный размер: канвас дополняется прозрачным до кратности", () => { it("non-multiple size: canvas padded transparent to multiple", () => {
const parts = splitToTileSize(grid(5, 3), 3, 2); const parts = splitToTileSize(grid(5, 3), 3, 2);
expect(parts).toHaveLength(4); expect(parts).toHaveLength(4);
for (const part of parts) { for (const part of parts) {
@@ -376,7 +376,7 @@ describe("splitToTileSize", () => {
]); ]);
}); });
it("тайл больше картинки — одна часть с паддингом", () => { it("tile bigger than image - one part with padding", () => {
const parts = splitToTileSize(grid(2, 2), 4, 4); const parts = splitToTileSize(grid(2, 2), 4, 4);
expect(parts).toHaveLength(1); expect(parts).toHaveLength(1);
expect(parts[0].width).toBe(4); expect(parts[0].width).toBe(4);
+12 -13
View File
@@ -58,10 +58,9 @@ export function tile(
} }
/** /**
* Разрезает изображение на сетку columns × rows строго равных частей. * Splits the image into a columns x rows grid of strictly equal parts.
* Холст дополняется прозрачным до кратного размера (`ceil(width / cols)`), * Canvas transparently padded to a multiple size, so all content survives.
* поэтому весь исходный контент сохраняется. Порядок — row-major: сначала все * Row-major order: row 1 columns first (part 1-1, 1-2, ...), then row 2, etc.
* столбцы первой строки (part 1-1, 1-2, …), затем второй и т.д.
*/ */
export function splitToParts( export function splitToParts(
img: PixelImage, img: PixelImage,
@@ -87,7 +86,7 @@ export function splitToParts(
return parts; return parts;
} }
/** Нарезка по размеру тайла: канвас дополняется прозрачным до кратности. */ /** Split by tile size: canvas transparently padded to a multiple. */
export function splitToTileSize( export function splitToTileSize(
img: PixelImage, img: PixelImage,
tileW: number, tileW: number,
@@ -301,7 +300,7 @@ export type Anchor9 =
| "bottom-center" | "bottom-center"
| "bottom-right"; | "bottom-right";
/** Границы контента: пиксели с альфой строго больше порога. Пустое изображение → null. */ /** Content bounds: pixels with alpha strictly above threshold. Empty image -> null. */
export function contentBounds( export function contentBounds(
img: PixelImage, img: PixelImage,
alphaThreshold = 0, alphaThreshold = 0,
@@ -324,14 +323,14 @@ export function contentBounds(
return { x: minX, y: minY, w: maxX - minX + 1, h: maxY - minY + 1 }; return { x: minX, y: minY, w: maxX - minX + 1, h: maxY - minY + 1 };
} }
/** Обрезка прозрачных полей по порогу альфы. Полностью пустое → 1×1 прозрачный пиксель. */ /** Trims transparent margins by alpha threshold. Fully empty -> 1x1 transparent pixel. */
export function trimToContent(img: PixelImage, alphaThreshold = 0): PixelImage { export function trimToContent(img: PixelImage, alphaThreshold = 0): PixelImage {
const b = contentBounds(img, alphaThreshold); const b = contentBounds(img, alphaThreshold);
if (!b) return crop(img, 0, 0, 1, 1); if (!b) return crop(img, 0, 0, 1, 1);
return crop(img, b.x, b.y, b.w, b.h); return crop(img, b.x, b.y, b.w, b.h);
} }
/** Приводит холст к точному размеру: лишнее обрезается, недостающее дополняется прозрачным. */ /** Fits the canvas to an exact size: excess is cropped, missing space transparently padded. */
export function changeCanvasSize( export function changeCanvasSize(
img: PixelImage, img: PixelImage,
width: number, width: number,
@@ -366,7 +365,7 @@ export function changeCanvasSize(
return out; return out;
} }
/** Центральный кроп до соотношения сторон (ratio ≥ 1 = широкое). */ /** Center crop to an aspect ratio (ratio >= 1 = wide). */
export function cropToRatio(img: PixelImage, ratio: number): PixelImage { export function cropToRatio(img: PixelImage, ratio: number): PixelImage {
const current = img.width / img.height; const current = img.width / img.height;
if (current > ratio) { if (current > ratio) {
@@ -380,7 +379,7 @@ export function cropToRatio(img: PixelImage, ratio: number): PixelImage {
return clonePixelImage(img); return clonePixelImage(img);
} }
/** Вписывает в соотношение сторон, добавляя прозрачные поля. */ /** Fits into an aspect ratio by adding transparent margins. */
export function padToRatio(img: PixelImage, ratio: number): PixelImage { export function padToRatio(img: PixelImage, ratio: number): PixelImage {
const current = img.width / img.height; const current = img.width / img.height;
let w = img.width; let w = img.width;
@@ -392,7 +391,7 @@ export function padToRatio(img: PixelImage, ratio: number): PixelImage {
return changeCanvasSize(img, w, h, "center"); return changeCanvasSize(img, w, h, "center");
} }
/** Разворачивает изображение на 90°, если его ориентация не совпадает с целевой. Квадрат не трогает. */ /** Rotates the image 90 degrees if its orientation differs from the target. Square untouched. */
export function forceOrientation( export function forceOrientation(
img: PixelImage, img: PixelImage,
target: "portrait" | "landscape", target: "portrait" | "landscape",
@@ -408,8 +407,8 @@ export function forceOrientation(
} }
/** /**
* Симметричная копия: к выбранной стороне оригинала добавляется его зеркало. * Symmetric copy: the chosen side of the original gets its mirror appended.
* axis vertical — зеркалим по вертикальной линии (ширина ×2), horizontal — по горизонтальной (высота ×2). * axis vertical mirrors across a vertical line (width x2), horizontal across a horizontal one (height x2).
*/ */
export function symmetricCopy( export function symmetricCopy(
img: PixelImage, img: PixelImage,
+8 -8
View File
@@ -13,23 +13,23 @@ describe("isSupportedImage", () => {
"image/gif", "image/gif",
"image/bmp", "image/bmp",
"image/x-icon", "image/x-icon",
])("принимает %s", (type) => { ])("accepts %s", (type) => {
expect(isSupportedImage(new File([], "x", { type }))).toBe(true); expect(isSupportedImage(new File([], "x", { type }))).toBe(true);
}); });
it("отклоняет неподдерживаемый тип", () => { it("rejects unsupported type", () => {
expect( expect(
isSupportedImage(new File([], "a.txt", { type: "text/plain" })), isSupportedImage(new File([], "a.txt", { type: "text/plain" })),
).toBe(false); ).toBe(false);
}); });
it("отклоняет файл без типа", () => { it("rejects file without type", () => {
expect(isSupportedImage(new File([], "x"))).toBe(false); expect(isSupportedImage(new File([], "x"))).toBe(false);
}); });
}); });
describe("unsupportedImageError", () => { describe("unsupportedImageError", () => {
it("ключ ошибки и тип файла в vars", () => { it("error key and file type in vars", () => {
const err = unsupportedImageError( const err = unsupportedImageError(
new File([], "a.txt", { type: "text/plain" }), new File([], "a.txt", { type: "text/plain" }),
); );
@@ -37,7 +37,7 @@ describe("unsupportedImageError", () => {
expect(err.vars?.type).toBe("text/plain"); expect(err.vars?.type).toBe("text/plain");
}); });
it("пустой тип передаётся как unknown", () => { it("empty type passed as unknown", () => {
const err = unsupportedImageError(new File([], "x")); const err = unsupportedImageError(new File([], "x"));
expect(err.vars?.type).toBe("unknown"); expect(err.vars?.type).toBe("unknown");
}); });
@@ -45,7 +45,7 @@ describe("unsupportedImageError", () => {
describe("validateOutputQuality", () => { describe("validateOutputQuality", () => {
it.each(["image/jpeg", "image/webp"] as const)( it.each(["image/jpeg", "image/webp"] as const)(
"принимает границы 0 и 1 для %s", "accepts bounds 0 and 1 for %s",
(mime) => { (mime) => {
expect(() => validateOutputQuality(mime, 0)).not.toThrow(); expect(() => validateOutputQuality(mime, 0)).not.toThrow();
expect(() => validateOutputQuality(mime, 1)).not.toThrow(); expect(() => validateOutputQuality(mime, 1)).not.toThrow();
@@ -53,7 +53,7 @@ describe("validateOutputQuality", () => {
); );
it.each(["image/jpeg", "image/webp"] as const)( it.each(["image/jpeg", "image/webp"] as const)(
"отклоняет значение вне 0..1 для %s", "rejects value outside 0..1 for %s",
(mime) => { (mime) => {
expect(() => validateOutputQuality(mime, -0.01)).toThrow( expect(() => validateOutputQuality(mime, -0.01)).toThrow(
"errors.qualityRange", "errors.qualityRange",
@@ -67,7 +67,7 @@ describe("validateOutputQuality", () => {
}, },
); );
it("не применяет quality к PNG и BMP", () => { it("ignores quality for PNG and BMP", () => {
expect(() => validateOutputQuality("image/png", 2)).not.toThrow(); expect(() => validateOutputQuality("image/png", 2)).not.toThrow();
expect(() => validateOutputQuality("image/bmp", 2)).not.toThrow(); expect(() => validateOutputQuality("image/bmp", 2)).not.toThrow();
}); });
+40 -4
View File
@@ -1,5 +1,7 @@
import { encodeBmpBytes } from "./bmp"; import { encodeBmpBytes } from "./bmp";
import { findMaxColorsWithin, findQualityWithin } from "./compress";
import { ToolError } from "./errors"; import { ToolError } from "./errors";
import { quantizeImage } from "./quantize";
import { type PixelImage } from "./types"; import { type PixelImage } from "./types";
export type OutputMime = export type OutputMime =
@@ -10,7 +12,7 @@ export interface OutputFormatOption {
ext: string; ext: string;
label: string; label: string;
supportsAlpha: boolean; supportsAlpha: boolean;
/** Точные настройки формата в dropdown кнопки Download (расширяемо). */ /** Fine-grained format settings in the Download button dropdown (extensible). */
settings?: { settings?: {
quality?: { min: number; max: number; step: number; default: number }; quality?: { min: number; max: number; step: number; default: number };
}; };
@@ -18,7 +20,7 @@ export interface OutputFormatOption {
const QUALITY_SETTING = { min: 1, max: 100, step: 1, default: 90 } as const; const QUALITY_SETTING = { min: 1, max: 100, step: 1, default: 90 } as const;
/** Варианты формата в кнопке Download (селектор рядом с ней). */ /** Format options in the Download button (selector next to it). */
export const OUTPUT_FORMATS: readonly OutputFormatOption[] = [ export const OUTPUT_FORMATS: readonly OutputFormatOption[] = [
{ mime: "image/png", ext: "png", label: "PNG", supportsAlpha: true }, { mime: "image/png", ext: "png", label: "PNG", supportsAlpha: true },
{ {
@@ -59,6 +61,14 @@ export function unsupportedImageError(file: File): ToolError {
}); });
} }
async function toBitmap(source: Blob | File): Promise<ImageBitmap> {
try {
return await createImageBitmap(source);
} catch {
throw new ToolError("errors.imageDecode");
}
}
async function decodeBitmap(bitmap: ImageBitmap): Promise<PixelImage> { async function decodeBitmap(bitmap: ImageBitmap): Promise<PixelImage> {
const canvas = document.createElement("canvas"); const canvas = document.createElement("canvas");
canvas.width = bitmap.width; canvas.width = bitmap.width;
@@ -77,7 +87,7 @@ async function decodeBitmap(bitmap: ImageBitmap): Promise<PixelImage> {
} }
export async function decodeFile(file: File): Promise<PixelImage> { export async function decodeFile(file: File): Promise<PixelImage> {
const bitmap = await createImageBitmap(file); const bitmap = await toBitmap(file);
try { try {
return await decodeBitmap(bitmap); return await decodeBitmap(bitmap);
} finally { } finally {
@@ -89,7 +99,7 @@ export async function decodeBytes(
bytes: Uint8Array<ArrayBuffer>, bytes: Uint8Array<ArrayBuffer>,
): Promise<PixelImage> { ): Promise<PixelImage> {
const blob = new Blob([bytes]); const blob = new Blob([bytes]);
const bitmap = await createImageBitmap(blob); const bitmap = await toBitmap(blob);
try { try {
return await decodeBitmap(bitmap); return await decodeBitmap(bitmap);
} finally { } finally {
@@ -173,6 +183,32 @@ function canvasToBlob(
}); });
} }
/**
* Encodes img to fit targetBytes: binary search over quality for lossy
* formats, over quantization colors for PNG. BMP has no compression knobs --
* returned as-is (the caller warns the user the limit does not apply).
*/
export async function fitWithinBytes(
img: PixelImage,
mime: OutputMime,
targetBytes: number,
quality?: number,
): Promise<Blob> {
if (mime === "image/bmp") return encode(img, mime);
const initial = await encode(img, mime, quality);
if (initial.size <= targetBytes) return initial;
if (mime === "image/png") {
const encodeSize = async (k: number) =>
(await encode(quantizeImage(img, k).image, mime)).size;
const k = await findMaxColorsWithin(targetBytes, 256, encodeSize);
return encode(quantizeImage(img, k).image, mime);
}
const encodeSize = async (q: number) =>
(await encode(img, mime, q / 100)).size;
const q = await findQualityWithin(targetBytes, encodeSize);
return encode(img, mime, q / 100);
}
export function downloadBlob(blob: Blob, filename: string): void { export function downloadBlob(blob: Blob, filename: string): void {
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
const a = document.createElement("a"); const a = document.createElement("a");
+31 -7
View File
@@ -1,5 +1,6 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { import {
diffMask,
extractByColor, extractByColor,
isGrayscaleish, isGrayscaleish,
luma01, luma01,
@@ -14,26 +15,26 @@ const px = makeImage(2, 1, [
]); ]);
describe("isGrayscaleish / luma01", () => { describe("isGrayscaleish / luma01", () => {
it("серый распознаётся с допуском, цветной — нет", () => { it("gray recognized within tolerance, colored not", () => {
expect(isGrayscaleish(10, 10, 10, 0)).toBe(true); expect(isGrayscaleish(10, 10, 10, 0)).toBe(true);
expect(isGrayscaleish(12, 10, 11, 2)).toBe(true); expect(isGrayscaleish(12, 10, 11, 2)).toBe(true);
expect(isGrayscaleish(255, 0, 0, 0)).toBe(false); expect(isGrayscaleish(255, 0, 0, 0)).toBe(false);
}); });
it("luma01: белый 1, чёрный 0", () => { it("luma01: white 1, black 0", () => {
expect(luma01(255, 255, 255)).toBeCloseTo(1); expect(luma01(255, 255, 255)).toBeCloseTo(1);
expect(luma01(0, 0, 0)).toBeCloseTo(0); expect(luma01(0, 0, 0)).toBeCloseTo(0);
}); });
}); });
describe("renderPredicateMask", () => { describe("renderPredicateMask", () => {
it("binary: совпавшие белые на чёрном, альфа принудительная", () => { it("binary: matches white on black, alpha forced", () => {
const out = renderPredicateMask(px, (r) => r > 200, { mode: "binary" }); const out = renderPredicateMask(px, (r) => r > 200, { mode: "binary" });
expect([...out.data.slice(0, 4)]).toEqual([255, 255, 255, 255]); expect([...out.data.slice(0, 4)]).toEqual([255, 255, 255, 255]);
expect([...out.data.slice(4, 8)]).toEqual([0, 0, 0, 255]); expect([...out.data.slice(4, 8)]).toEqual([0, 0, 0, 255]);
}); });
it("highlight: подкрашивает только совпавшие, остальные без изменений", () => { it("highlight: tints only matches, rest unchanged", () => {
const out = renderPredicateMask(px, (r) => r > 200, { const out = renderPredicateMask(px, (r) => r > 200, {
mode: "highlight", mode: "highlight",
color: "#0000ff", color: "#0000ff",
@@ -43,7 +44,7 @@ describe("renderPredicateMask", () => {
expect([...out.data.slice(4, 8)]).toEqual([10, 10, 10, 255]); expect([...out.data.slice(4, 8)]).toEqual([10, 10, 10, 255]);
}); });
it("прозрачность оригинала сохраняется в highlight-режиме", () => { it("original alpha preserved in highlight mode", () => {
const semi = makeImage(1, 1, [[200, 200, 200, 128]]); const semi = makeImage(1, 1, [[200, 200, 200, 128]]);
const out = renderPredicateMask(semi, () => true, { const out = renderPredicateMask(semi, () => true, {
mode: "highlight", mode: "highlight",
@@ -62,15 +63,38 @@ describe("rarityPredicate + extractByColor", () => {
[0, 255, 0, 255], [0, 255, 0, 255],
]); ]);
it("уникальный (единичный) цвет находится, массовый — нет", () => { it("unique (single) color found, common not", () => {
const pred = rarityPredicate(img, 1); const pred = rarityPredicate(img, 1);
expect(pred(0, 255, 0, 255)).toBe(true); expect(pred(0, 255, 0, 255)).toBe(true);
expect(pred(255, 0, 0, 255)).toBe(false); expect(pred(255, 0, 0, 255)).toBe(false);
}); });
it("extractByColor оставляет близкие и делает дальние прозрачными", () => { it("extractByColor keeps near and makes far transparent", () => {
const out = extractByColor(px, "#0a0a0a", 5); const out = extractByColor(px, "#0a0a0a", 5);
expect(out.data[3]).toBe(0); expect(out.data[3]).toBe(0);
expect(out.data[7]).toBe(255); expect(out.data[7]).toBe(255);
}); });
}); });
describe("diffMask", () => {
const before = makeImage(2, 1, [
[255, 0, 0, 255],
[0, 255, 0, 128],
]);
it("white where channels differ, black where equal, always opaque", () => {
const after = makeImage(2, 1, [
[255, 0, 0, 255],
[0, 255, 0, 255],
]);
const out = diffMask(before, after);
expect([...out.data.slice(0, 4)]).toEqual([0, 0, 0, 255]);
expect([...out.data.slice(4, 8)]).toEqual([255, 255, 255, 255]);
});
it("size mismatch means everything changed", () => {
const smaller = makeImage(1, 1, [[255, 0, 0, 255]]);
const out = diffMask(before, smaller);
expect([...out.data.slice(0, 4)]).toEqual([255, 255, 255, 255]);
});
});
+31 -7
View File
@@ -5,15 +5,15 @@ import { createPixelImage } from "./types";
export type MaskMode = "binary" | "highlight"; export type MaskMode = "binary" | "highlight";
export interface MaskOptions { export interface MaskOptions {
/** binary: белое/чёрное без альфы; highlight: подкрасить совпавшие пиксели цветом. */ /** binary: white/black without alpha; highlight: tint matching pixels. */
mode?: MaskMode; mode?: MaskMode;
color?: string; color?: string;
opacityPercent?: number; opacityPercent?: number;
} }
/** /**
* Единый рендер масок: предикат решает, совпал ли пиксель; * Unified mask renderer: the predicate decides whether a pixel matches;
* режим определяет вид результата. * the mode defines the look of the result.
*/ */
export function renderPredicateMask( export function renderPredicateMask(
img: PixelImage, img: PixelImage,
@@ -25,7 +25,7 @@ export function renderPredicateMask(
const tint = o.color ? hexToRgb(o.color) : { r: 255, g: 0, b: 170 }; const tint = o.color ? hexToRgb(o.color) : { r: 255, g: 0, b: 170 };
const opacity = Math.min(Math.max(o.opacityPercent ?? 70, 0), 100) / 100; const opacity = Math.min(Math.max(o.opacityPercent ?? 70, 0), 100) / 100;
if (!highlight) { if (!highlight) {
// Бинарная маска — непрозрачное белое-на-чёрном // Binary mask: opaque white-on-black
for (let a = 3; a < out.data.length; a += 4) out.data[a] = 255; for (let a = 3; a < out.data.length; a += 4) out.data[a] = 255;
} }
for (let i = 0; i < img.data.length; i += 4) { for (let i = 0; i < img.data.length; i += 4) {
@@ -86,8 +86,8 @@ export function luma01(r: number, g: number, b: number): number {
} }
/** /**
* Оставляет пиксели, близкие к целевому цвету, остальное делает прозрачным. * Keeps pixels close to the target color, makes the rest transparent.
* Допуск — в процентах от максимального поканального разброса (255). * Tolerance is a percentage of the max per-channel delta (255).
*/ */
export function extractByColor( export function extractByColor(
img: PixelImage, img: PixelImage,
@@ -118,7 +118,31 @@ export function extractByColor(
} }
/** /**
* Считает частоты цветов и возвращает предикат «встречается не чаще limit раз». * Pixels that differ between before/after become white, the rest black.
* Size mismatch (crop/trim) means everything changed: all white.
*/
export function diffMask(before: PixelImage, after: PixelImage): PixelImage {
const out = createPixelImage(before.width, before.height);
const sameSize =
before.width === after.width && before.height === after.height;
for (let i = 0; i < out.data.length; i += 4) {
const changed =
!sameSize ||
before.data[i] !== after.data[i] ||
before.data[i + 1] !== after.data[i + 1] ||
before.data[i + 2] !== after.data[i + 2] ||
before.data[i + 3] !== after.data[i + 3];
const v = changed ? 255 : 0;
out.data[i] = v;
out.data[i + 1] = v;
out.data[i + 2] = v;
out.data[i + 3] = 255;
}
return out;
}
/**
* Counts color frequencies and returns a predicate "occurs at most limit times".
*/ */
export function rarityPredicate( export function rarityPredicate(
img: PixelImage, img: PixelImage,
+34 -12
View File
@@ -3,6 +3,7 @@ import {
buildAlphaMask, buildAlphaMask,
closingImage, closingImage,
contourImage, contourImage,
contourMask,
dilateImage, dilateImage,
dilateMask, dilateMask,
erodeImage, erodeImage,
@@ -34,7 +35,7 @@ function toRows(mask: Uint8Array, w: number): string[] {
} }
describe("dilateMask", () => { describe("dilateMask", () => {
it("одиночный пиксель r=1 превращается в плюс", () => { it("single pixel r=1 becomes a plus", () => {
const out = dilateMask( const out = dilateMask(
maskFrom([".....", ".....", "..#..", ".....", "....."]), maskFrom([".....", ".....", "..#..", ".....", "....."]),
5, 5,
@@ -50,7 +51,7 @@ describe("dilateMask", () => {
]); ]);
}); });
it("r=2 даёт ромб радиуса 2", () => { it("r=2 yields diamond of radius 2", () => {
const out = dilateMask( const out = dilateMask(
maskFrom([".....", ".....", "..#..", ".....", "....."]), maskFrom([".....", ".....", "..#..", ".....", "....."]),
5, 5,
@@ -68,12 +69,12 @@ describe("dilateMask", () => {
}); });
describe("erodeMask", () => { describe("erodeMask", () => {
it("сплошной объект во весь кадр не сжимается от границ", () => { it("full-frame solid object not shrunk from borders", () => {
const solid = maskFrom(["#####", "#####", "#####", "#####", "#####"]); const solid = maskFrom(["#####", "#####", "#####", "#####", "#####"]);
expect([...erodeMask(solid, 5, 5, 1)]).toEqual([...solid]); expect([...erodeMask(solid, 5, 5, 1)]).toEqual([...solid]);
}); });
it("изолированный пиксель исчезает", () => { it("isolated pixel vanishes", () => {
const out = erodeMask( const out = erodeMask(
maskFrom([".....", ".....", "..#..", ".....", "....."]), maskFrom([".....", ".....", "..#..", ".....", "....."]),
5, 5,
@@ -84,8 +85,8 @@ describe("erodeMask", () => {
}); });
}); });
describe("opening/closing образы", () => { describe("opening/closing images", () => {
it("opening убирает отстоящий мусорный пиксель и сохраняет блок", () => { it("opening removes stray noise pixel and keeps block", () => {
const B = [10, 10, 10, 255]; const B = [10, 10, 10, 255];
const T = [0, 0, 0, 0]; const T = [0, 0, 0, 0];
const S = [200, 200, 200, 255]; const S = [200, 200, 200, 255];
@@ -99,7 +100,7 @@ describe("opening/closing образы", () => {
expect(at(4, 1)).toBe(0); expect(at(4, 1)).toBe(0);
}); });
it("closing заполняет одиночную прозрачную дыру чёрным", () => { it("closing fills single transparent hole with black", () => {
const pixels: number[][] = []; const pixels: number[][] = [];
for (let y = 0; y < 3; y++) { for (let y = 0; y < 3; y++) {
for (let x = 0; x < 3; x++) { for (let x = 0; x < 3; x++) {
@@ -112,8 +113,8 @@ describe("opening/closing образы", () => {
}); });
}); });
describe("image-обёртки", () => { describe("image wrappers", () => {
it("dilateImage сохраняет RGB содержимого, новые пиксели непрозрачны", () => { it("dilateImage keeps content RGB, new pixels opaque", () => {
const T = [0, 0, 0, 0]; const T = [0, 0, 0, 0];
const O = [200, 50, 25, 255]; const O = [200, 50, 25, 255];
const img = makeImage(5, 2, [T, O, T, T, T, T, T, T, T, T]); const img = makeImage(5, 2, [T, O, T, T, T, T, T, T, T, T]);
@@ -132,7 +133,7 @@ describe("image-обёртки", () => {
expect(at(4, 0)[3]).toBe(0); expect(at(4, 0)[3]).toBe(0);
}); });
it("erodeImage не стирает объект у края кадра", () => { it("erodeImage does not erase object at frame edge", () => {
const img = makeImage(2, 2, [ const img = makeImage(2, 2, [
[10, 20, 30, 255], [10, 20, 30, 255],
[10, 20, 30, 255], [10, 20, 30, 255],
@@ -147,7 +148,7 @@ describe("image-обёртки", () => {
}); });
describe("strokeImage", () => { describe("strokeImage", () => {
it("кольцо цвета обводки вокруг квадрата", () => { it("ring of stroke color around square", () => {
const pixels: number[][] = []; const pixels: number[][] = [];
for (let y = 0; y < 7; y++) { for (let y = 0; y < 7; y++) {
for (let x = 0; x < 7; x++) { for (let x = 0; x < 7; x++) {
@@ -169,7 +170,7 @@ describe("strokeImage", () => {
}); });
describe("contourImage", () => { describe("contourImage", () => {
it("линия по краю квадрата, центр прозрачен", () => { it("line along square edge, center transparent", () => {
const pixels: number[][] = []; const pixels: number[][] = [];
for (let y = 0; y < 7; y++) { for (let y = 0; y < 7; y++) {
for (let x = 0; x < 7; x++) { for (let x = 0; x < 7; x++) {
@@ -189,3 +190,24 @@ describe("contourImage", () => {
expect(at(1, 3)).toEqual([0, 0, 0, 0]); expect(at(1, 3)).toEqual([0, 0, 0, 0]);
}); });
}); });
describe("contourMask", () => {
it("white line on the alpha edge, black elsewhere", () => {
const pixels = [];
for (let y = 0; y < 7; y++) {
for (let x = 0; x < 7; x++) {
pixels.push(
x >= 2 && x <= 4 && y >= 2 && y <= 4
? [255, 255, 255, 255]
: [0, 0, 0, 0],
);
}
}
const out = contourMask(makeImage(7, 7, pixels), 1);
const at = (x: number, y: number) => out.data[(y * 7 + x) * 4];
expect(at(2, 2)).toBe(255);
expect(at(3, 3)).toBe(0);
expect(at(0, 0)).toBe(0);
expect(out.data[3]).toBe(255);
});
});
+17
View File
@@ -167,6 +167,23 @@ export function contourImage(
return out; return out;
} }
/** B/w preview of the contour line: white where the stroke will land. */
export function contourMask(img: PixelImage, radiusPx: number): PixelImage {
const r = Math.max(1, Math.trunc(radiusPx));
const mask = buildAlphaMask(img);
const inner = erodeMask(mask, img.width, img.height, r);
const out = createPixelImage(img.width, img.height);
for (let i = 0; i < mask.length; i++) {
const v = mask[i] === 1 && inner[i] === 0 ? 255 : 0;
const di = i * 4;
out.data[di] = v;
out.data[di + 1] = v;
out.data[di + 2] = v;
out.data[di + 3] = 255;
}
return out;
}
export function openingMask( export function openingMask(
mask: Mask, mask: Mask,
w: number, w: number,
+21 -21
View File
@@ -19,8 +19,8 @@ import {
tetradicSet, tetradicSet,
} from "./palette"; } from "./palette";
describe("конвертация rgb↔hsl", () => { describe("rgb<->hsl conversion", () => {
it("красный → hsl(0,100%,50%) и обратно", () => { it("red -> hsl(0,100%,50%) and back", () => {
const hsl = rgbToHsl(hexToRgb("#ff0000")); const hsl = rgbToHsl(hexToRgb("#ff0000"));
expect(hsl.h).toBeCloseTo(0, 0); expect(hsl.h).toBeCloseTo(0, 0);
expect(hsl.s).toBeCloseTo(1, 5); expect(hsl.s).toBeCloseTo(1, 5);
@@ -28,11 +28,11 @@ describe("конвертация rgb↔hsl", () => {
expect(rgbToHex(hslToRgb({ h: 0, s: 1, l: 0.5 }))).toBe("#ff0000"); expect(rgbToHex(hslToRgb({ h: 0, s: 1, l: 0.5 }))).toBe("#ff0000");
}); });
it("серые не имеют оттенка", () => { it("grays have no hue", () => {
expect(rgbToHsl(hexToRgb("#808080")).s).toBe(0); expect(rgbToHsl(hexToRgb("#808080")).s).toBe(0);
}); });
it("круговой переход 360° возвращает исходный цвет", () => { it("360 deg wraparound returns original color", () => {
const base = "#3b82f6"; const base = "#3b82f6";
expect( expect(
rgbToHex(hslToRgb({ ...rgbToHsl(hexToRgb(base)), h: 720 + 30 })), rgbToHex(hslToRgb({ ...rgbToHsl(hexToRgb(base)), h: 720 + 30 })),
@@ -40,8 +40,8 @@ describe("конвертация rgb↔hsl", () => {
}); });
}); });
describe("гармонии", () => { describe("harmonies", () => {
it("complementary — пара с сдвигом 180°", () => { it("complementary - pair shifted 180 deg", () => {
const [a, b] = complementarySet("#ff0000"); const [a, b] = complementarySet("#ff0000");
expect(a).toBe("#ff0000"); expect(a).toBe("#ff0000");
const ha = rgbToHsl(hexToRgb(a)).h; const ha = rgbToHsl(hexToRgb(a)).h;
@@ -49,7 +49,7 @@ describe("гармонии", () => {
expect(Math.abs((hb - ha + 360) % 360)).toBeCloseTo(180, 0); expect(Math.abs((hb - ha + 360) % 360)).toBeCloseTo(180, 0);
}); });
it("triadic — три цвета через 120°", () => { it("triadic - three colors 120 deg apart", () => {
const set = triadicSet("#ff0000"); const set = triadicSet("#ff0000");
expect(set).toHaveLength(3); expect(set).toHaveLength(3);
const hues = set.map((h) => rgbToHsl(hexToRgb(h)).h); const hues = set.map((h) => rgbToHsl(hexToRgb(h)).h);
@@ -57,18 +57,18 @@ describe("гармонии", () => {
expect(hues[2] - hues[0]).toBeCloseTo(240, 0); expect(hues[2] - hues[0]).toBeCloseTo(240, 0);
}); });
it("tetradic — четыре цвета через 90°", () => { it("tetradic - four colors 90 deg apart", () => {
const set = tetradicSet("#00ff00"); const set = tetradicSet("#00ff00");
expect(set).toHaveLength(4); expect(set).toHaveLength(4);
}); });
it("analogous симметричен вокруг базы", () => { it("analogous symmetric around base", () => {
const set = analogousSet("#0000ff", 30, 5); const set = analogousSet("#0000ff", 30, 5);
expect(set).toHaveLength(5); expect(set).toHaveLength(5);
expect(set[2]).toBe(normalizeHex("#0000ff")); expect(set[2]).toBe(normalizeHex("#0000ff"));
}); });
it("monochromatic держит оттенок, меняет светлоту", () => { it("monochromatic keeps hue, varies lightness", () => {
const set = monochromaticSet("#ff8800", 5, 60); const set = monochromaticSet("#ff8800", 5, 60);
expect(set).toHaveLength(5); expect(set).toHaveLength(5);
const hues = new Set(set.map((h) => Math.round(rgbToHsl(hexToRgb(h)).h))); const hues = new Set(set.map((h) => Math.round(rgbToHsl(hexToRgb(h)).h)));
@@ -77,7 +77,7 @@ describe("гармонии", () => {
expect(Math.min(...lights)).toBeLessThan(Math.max(...lights)); expect(Math.min(...lights)).toBeLessThan(Math.max(...lights));
}); });
it("shades — тёмный край темнее базы", () => { it("shades - dark edge darker than base", () => {
const set = shadeSet("#88cc44", 4, 70); const set = shadeSet("#88cc44", 4, 70);
const lights = set.map((h) => rgbToHsl(hexToRgb(h)).l); const lights = set.map((h) => rgbToHsl(hexToRgb(h)).l);
expect(lights[lights.length - 1]).toBeLessThan(lights[0]); expect(lights[lights.length - 1]).toBeLessThan(lights[0]);
@@ -85,30 +85,30 @@ describe("гармонии", () => {
}); });
describe("parseHexList / mixColors / sortPalette", () => { describe("parseHexList / mixColors / sortPalette", () => {
it("парсит список и отбрасывает мусор и токены без решётки", () => { it("parses list, drops junk and hash-less tokens", () => {
expect(parseHexList("#ff0000, #00FF00 ; 00ff00 zz")).toEqual([ expect(parseHexList("#ff0000, #00FF00 ; 00ff00 zz")).toEqual([
"#ff0000", "#ff0000",
"#00ff00", "#00ff00",
]); ]);
}); });
it("пустой валидный список бросает badHex", () => { it("empty valid list throws badHex", () => {
expect(() => parseHexList("нет цветов")).toThrow(/errors\.badHex/); expect(() => parseHexList("no colors")).toThrow(/errors\.badHex/);
}); });
it("mixColors — среднее компонент", () => { it("mixColors - component average", () => {
expect(mixColors(["#000000", "#ffffff"])).toBe("#808080"); expect(mixColors(["#000000", "#ffffff"])).toBe("#808080");
}); });
it("sortPalette по luma ставит тёмные раньше", () => { it("sortPalette by luma puts dark first", () => {
const sorted = sortPalette(["#ffffff", "#000000", "#808080"], "luma"); const sorted = sortPalette(["#ffffff", "#000000", "#808080"], "luma");
expect(sorted[0]).toBe("#000000"); expect(sorted[0]).toBe("#000000");
expect(sorted[2]).toBe("#ffffff"); expect(sorted[2]).toBe("#ffffff");
}); });
}); });
describe("рендеры", () => { describe("renders", () => {
it("renderSwatches strip: колонки по цветам", () => { it("renderSwatches strip: columns by color", () => {
const img = renderSwatches(["#ff0000", "#00ff00"], 200, "strip"); const img = renderSwatches(["#ff0000", "#00ff00"], 200, "strip");
expect(img.width).toBe(200); expect(img.width).toBe(200);
expect(img.data[(Math.floor(img.height / 2) * 200 + 10) * 4 + 3]).toBe(255); expect(img.data[(Math.floor(img.height / 2) * 200 + 10) * 4 + 3]).toBe(255);
@@ -120,13 +120,13 @@ describe("рендеры", () => {
expect(img.data[right + 1]).toBeGreaterThan(200); expect(img.data[right + 1]).toBeGreaterThan(200);
}); });
it("renderSwatches grid: квадратные ячейки", () => { it("renderSwatches grid: square cells", () => {
const img = renderSwatches(["#111111", "#222222", "#333333"], 120, "grid"); const img = renderSwatches(["#111111", "#222222", "#333333"], 120, "grid");
expect(img.width).toBe(120); expect(img.width).toBe(120);
expect(img.height).toBeGreaterThan(0); expect(img.height).toBeGreaterThan(0);
}); });
it("renderWheel: углы прозрачны, центр непрозрачен", () => { it("renderWheel: corners transparent, center opaque", () => {
const size = 101; const size = 101;
const img = renderWheel(size, 50); const img = renderWheel(size, 50);
expect(img.data[3]).toBe(0); expect(img.data[3]).toBe(0);
@@ -134,7 +134,7 @@ describe("рендеры", () => {
expect(img.data[c + 3]).toBe(255); expect(img.data[c + 3]).toBe(255);
}); });
it("renderBlend: левый край = a, правый = b", () => { it("renderBlend: left edge = a, right = b", () => {
const img = renderBlend("#000000", "#ffffff", 100); const img = renderBlend("#000000", "#ffffff", 100);
expect(img.data[0]).toBe(0); expect(img.data[0]).toBe(0);
const last = 99 * 4; const last = 99 * 4;
+2 -2
View File
@@ -25,7 +25,7 @@ export function rgbToHex({ r, g, b }: Rgb): string {
return `#${byte(r)}${byte(g)}${byte(b)}`; return `#${byte(r)}${byte(g)}${byte(b)}`;
} }
/** h ∈ [0..360), s,l ∈ [0..1] */ /** h in [0..360), s,l in [0..1] */
export function rgbToHsl({ r, g, b }: Rgb): Hsl { export function rgbToHsl({ r, g, b }: Rgb): Hsl {
const rn = r / 255; const rn = r / 255;
const gn = g / 255; const gn = g / 255;
@@ -184,7 +184,7 @@ function clamp01(v: number): number {
return Math.min(1, Math.max(0, v)); return Math.min(1, Math.max(0, v));
} }
/** Горизонтальные равные колонки-свотчи (strip) или сетка ~квадратных ячеек (grid). */ /** Horizontal equal swatch columns (strip) or a grid of ~square cells (grid). */
export function renderSwatches( export function renderSwatches(
colors: string[], colors: string[],
width: number, width: number,
+13 -13
View File
@@ -11,7 +11,7 @@ import {
import { makeImage } from "./test-helpers"; import { makeImage } from "./test-helpers";
describe("pixelate", () => { describe("pixelate", () => {
it("блок усредняется: шахматка 2×2 с блоком 2 → один цвет", () => { it("block averaged: 2x2 checker with block 2 -> one color", () => {
const img = makeImage(2, 2, [ const img = makeImage(2, 2, [
[0, 0, 0, 255], [0, 0, 0, 255],
[200, 200, 200, 255], [200, 200, 200, 255],
@@ -23,14 +23,14 @@ describe("pixelate", () => {
expect(out.data[4]).toBeCloseTo(110, 0); expect(out.data[4]).toBeCloseTo(110, 0);
}); });
it("однотонное изображение не меняется", () => { it("solid image unchanged", () => {
const img = makeImage(3, 3, new Array(9).fill([50, 60, 70, 255])); const img = makeImage(3, 3, new Array(9).fill([50, 60, 70, 255]));
expect([...pixelate(img, 2).data]).toEqual([...img.data]); expect([...pixelate(img, 2).data]).toEqual([...img.data]);
}); });
}); });
describe("shuffleBlocks / addNoise — детерминизм по seed", () => { describe("shuffleBlocks / addNoise - seed determinism", () => {
it("тот же seed даёт то же перемешивание", () => { it("same seed gives same shuffle", () => {
const img = makeImage(4, 1, [ const img = makeImage(4, 1, [
[10, 0, 0, 255], [10, 0, 0, 255],
[20, 0, 0, 255], [20, 0, 0, 255],
@@ -42,7 +42,7 @@ describe("shuffleBlocks / addNoise — детерминизм по seed", () =>
expect([...a.data]).toEqual([...b.data]); expect([...a.data]).toEqual([...b.data]);
}); });
it("мультимножество пикселей сохраняется (перестановка)", () => { it("pixel multiset preserved (permutation)", () => {
const img = makeImage(4, 1, [ const img = makeImage(4, 1, [
[10, 0, 0, 255], [10, 0, 0, 255],
[20, 0, 0, 255], [20, 0, 0, 255],
@@ -55,7 +55,7 @@ describe("shuffleBlocks / addNoise — детерминизм по seed", () =>
expect(out).toEqual([10, 20, 30, 40]); expect(out).toEqual([10, 20, 30, 40]);
}); });
it("addNoise при том же seed воспроизводим, amount=0 — идентичность", () => { it("addNoise reproducible at same seed, amount=0 - identity", () => {
const img = makeImage(2, 2, new Array(4).fill([128, 64, 32, 255])); const img = makeImage(2, 2, new Array(4).fill([128, 64, 32, 255]));
const a = addNoise(img, 20, "mono", 5); const a = addNoise(img, 20, "mono", 5);
const b = addNoise(img, 20, "mono", 5); const b = addNoise(img, 20, "mono", 5);
@@ -65,8 +65,8 @@ describe("shuffleBlocks / addNoise — детерминизм по seed", () =>
}); });
describe("featherAlpha", () => { describe("featherAlpha", () => {
it("жёсткий край получает промежуточные альфы", () => { it("hard edge gets intermediate alphas", () => {
// левая половина непрозрачная, правая прозрачная // left half opaque, right transparent
const img = makeImage(6, 1, [ const img = makeImage(6, 1, [
[255, 0, 0, 255], [255, 0, 0, 255],
[255, 0, 0, 255], [255, 0, 0, 255],
@@ -83,7 +83,7 @@ describe("featherAlpha", () => {
}); });
describe("defringe", () => { describe("defringe", () => {
it("полупрозрачному пикселю берётся RGB от соседнего непрозрачного", () => { it("semi-transparent pixel takes RGB from opaque neighbor", () => {
const img = makeImage(2, 1, [ const img = makeImage(2, 1, [
[250, 250, 250, 255], [250, 250, 250, 255],
[255, 0, 0, 128], [255, 0, 0, 128],
@@ -91,10 +91,10 @@ describe("defringe", () => {
const out = defringe(img, 2); const out = defringe(img, 2);
expect(out.data[4]).toBe(250); expect(out.data[4]).toBe(250);
expect(out.data[6]).toBe(250); expect(out.data[6]).toBe(250);
expect(out.data[7]).toBe(128); // альфа сохранена expect(out.data[7]).toBe(128); // alpha preserved
}); });
it("полностью прозрачные области не трогаются", () => { it("fully transparent areas untouched", () => {
const img = makeImage(2, 1, [ const img = makeImage(2, 1, [
[250, 250, 250, 255], [250, 250, 250, 255],
[0, 0, 0, 0], [0, 0, 0, 0],
@@ -105,7 +105,7 @@ describe("defringe", () => {
}); });
describe("silhouette", () => { describe("silhouette", () => {
it("видимые пиксели заливаются цветом, ниже порога — прозрачность", () => { it("visible pixels filled with color, below threshold - transparent", () => {
const img = makeImage(2, 1, [ const img = makeImage(2, 1, [
[123, 45, 67, 255], [123, 45, 67, 255],
[123, 45, 67, 10], [123, 45, 67, 10],
@@ -117,7 +117,7 @@ describe("silhouette", () => {
}); });
describe("mulberry32", () => { describe("mulberry32", () => {
it("последовательность детерминирована", () => { it("sequence is deterministic", () => {
const a = mulberry32(42); const a = mulberry32(42);
const b = mulberry32(42); const b = mulberry32(42);
expect([a(), a(), a()]).toEqual([b(), b(), b()]); expect([a(), a(), a()]).toEqual([b(), b(), b()]);
+9 -8
View File
@@ -3,7 +3,7 @@ import type { PixelImage } from "./types";
import { createPixelImage } from "./types"; import { createPixelImage } from "./types";
import { gaussianBlur } from "./convolution"; import { gaussianBlur } from "./convolution";
/** Детерминированный ГПСЧ (mulberry32): одинаковый seed — одинаковый результат. */ /** Deterministic PRNG (mulberry32): same seed gives the same result. */
export function mulberry32(seed: number): () => number { export function mulberry32(seed: number): () => number {
let a = seed >>> 0; let a = seed >>> 0;
return () => { return () => {
@@ -19,7 +19,7 @@ function clampByte(v: number): number {
return v < 0 ? 0 : v > 255 ? 255 : Math.round(v); return v < 0 ? 0 : v > 255 ? 255 : Math.round(v);
} }
/** Усреднение каждого блока blockSize×BlockSize в его верхний-левый пиксель цвета. */ /** Averages each blockSize x blockSize block into a single color. */
export function pixelate(img: PixelImage, blockSize: number): PixelImage { export function pixelate(img: PixelImage, blockSize: number): PixelImage {
const bs = Math.max(1, Math.round(blockSize)); const bs = Math.max(1, Math.round(blockSize));
const out = createPixelImage(img.width, img.height); const out = createPixelImage(img.width, img.height);
@@ -58,7 +58,7 @@ export function pixelate(img: PixelImage, blockSize: number): PixelImage {
return out; return out;
} }
/** Перемешивает блоки blockSize×Blocksize между собой детерминированно по seed. */ /** Shuffles blockSize x blockSize blocks deterministically per seed. */
export function shuffleBlocks( export function shuffleBlocks(
img: PixelImage, img: PixelImage,
blockSize: number, blockSize: number,
@@ -98,7 +98,7 @@ export function shuffleBlocks(
export type NoiseMode = "mono" | "color"; export type NoiseMode = "mono" | "color";
/** Зерно: amountPercent — сила отклонения от оригинала. Детерминировано по seed. */ /** Grain: amountPercent is the deviation strength from the original. Deterministic per seed. */
export function addNoise( export function addNoise(
img: PixelImage, img: PixelImage,
amountPercent: number, amountPercent: number,
@@ -128,7 +128,7 @@ export function addNoise(
return out; return out;
} }
/** Размытие только альфа-канала: мягкие края при неизменном цвете. */ /** Blurs only the alpha channel: soft edges with unchanged color. */
export function featherAlpha(img: PixelImage, radius: number): PixelImage { export function featherAlpha(img: PixelImage, radius: number): PixelImage {
const gray = createPixelImage(img.width, img.height); const gray = createPixelImage(img.width, img.height);
for (let i = 0; i < img.data.length; i += 4) { for (let i = 0; i < img.data.length; i += 4) {
@@ -149,8 +149,9 @@ export function featherAlpha(img: PixelImage, radius: number): PixelImage {
} }
/** /**
* Убирает цветную кайму на полупрозрачных краях: RGB полупрозрачного пикселя * Removes the color fringe on semi-transparent edges: the RGB of a
* заменяется цветом ближайшего полностью непрозрачного соседа в пределах radius. * semi-transparent pixel is replaced by the color of the nearest fully
* opaque neighbor within radius.
*/ */
export function defringe(img: PixelImage, radius: number): PixelImage { export function defringe(img: PixelImage, radius: number): PixelImage {
const out = createPixelImage(img.width, img.height); const out = createPixelImage(img.width, img.height);
@@ -184,7 +185,7 @@ export function defringe(img: PixelImage, radius: number): PixelImage {
return out; return out;
} }
/** Силуэт: все видимые пиксели заливаются одним цветом, альфа сохраняется. */ /** Silhouette: all visible pixels filled with one color, alpha preserved. */
export function silhouette( export function silhouette(
img: PixelImage, img: PixelImage,
colorHex: string, colorHex: string,
+8 -8
View File
@@ -8,7 +8,7 @@ import {
import { makeImage } from "./test-helpers"; import { makeImage } from "./test-helpers";
describe("medianCutPalette", () => { describe("medianCutPalette", () => {
it("два явных кластера при k=2 дают сами цвета", () => { it("two clear clusters at k=2 yield the colors themselves", () => {
const img = makeImage(4, 1, [ const img = makeImage(4, 1, [
[0, 0, 0, 255], [0, 0, 0, 255],
[0, 0, 0, 255], [0, 0, 0, 255],
@@ -21,14 +21,14 @@ describe("medianCutPalette", () => {
expect(hexes).toEqual(["0,0,0", "255,255,255"]); expect(hexes).toEqual(["0,0,0", "255,255,255"]);
}); });
it("пустое изображение даёт чёрную заглушку", () => { it("empty image yields black placeholder", () => {
const empty = makeImage(2, 2, new Array(4).fill([0, 0, 0, 0])); const empty = makeImage(2, 2, new Array(4).fill([0, 0, 0, 0]));
expect(medianCutPalette(empty, 4)).toHaveLength(1); expect(medianCutPalette(empty, 4)).toHaveLength(1);
}); });
}); });
describe("quantizeImage / ditherImage", () => { describe("quantizeImage / ditherImage", () => {
it("квантование укладывает пиксели в палитру, прозрачность сохраняется", () => { it("quantization fits pixels into palette, keeps transparency", () => {
const img = makeImage(3, 1, [ const img = makeImage(3, 1, [
[10, 10, 10, 255], [10, 10, 10, 255],
[250, 250, 250, 255], [250, 250, 250, 255],
@@ -36,7 +36,7 @@ describe("quantizeImage / ditherImage", () => {
]); ]);
const { image, palette } = quantizeImage(img, 2); const { image, palette } = quantizeImage(img, 2);
expect(palette.length).toBeLessThanOrEqual(2); expect(palette.length).toBeLessThanOrEqual(2);
expect(image.data[2 * 4 + 3]).toBe(0); // прозрачный остался expect(image.data[2 * 4 + 3]).toBe(0); // transparent kept
for (let x = 0; x < 2; x++) { for (let x = 0; x < 2; x++) {
const i = x * 4; const i = x * 4;
const matched = palette.some((hex) => { const matched = palette.some((hex) => {
@@ -47,7 +47,7 @@ describe("quantizeImage / ditherImage", () => {
} }
}); });
it("floyd-steinberg расщепляет серый 50% на чёрное и белое", () => { it("floyd-steinberg splits 50% gray into black and white", () => {
const gray = makeImage(16, 16, new Array(256).fill([128, 128, 128, 255])); const gray = makeImage(16, 16, new Array(256).fill([128, 128, 128, 255]));
const out = ditherImage(gray, 2, "floyd-steinberg", ["#000000", "#ffffff"]); const out = ditherImage(gray, 2, "floyd-steinberg", ["#000000", "#ffffff"]);
let hasDark = false; let hasDark = false;
@@ -60,14 +60,14 @@ describe("quantizeImage / ditherImage", () => {
expect(hasLight).toBe(true); expect(hasLight).toBe(true);
}); });
it("bayer детерминирован", () => { it("bayer is deterministic", () => {
const gray = makeImage(8, 8, new Array(64).fill([128, 128, 128, 255])); const gray = makeImage(8, 8, new Array(64).fill([128, 128, 128, 255]));
const a = ditherImage(gray, 2, "bayer"); const a = ditherImage(gray, 2, "bayer");
const b = ditherImage(gray, 2, "bayer"); const b = ditherImage(gray, 2, "bayer");
expect([...a.data]).toEqual([...b.data]); expect([...a.data]).toEqual([...b.data]);
}); });
it("полностью прозрачное изображение не падает", () => { it("fully transparent image does not crash", () => {
const empty = makeImage(2, 2, new Array(4).fill([0, 0, 0, 0])); const empty = makeImage(2, 2, new Array(4).fill([0, 0, 0, 0]));
const out = ditherImage(empty, 4, "bayer"); const out = ditherImage(empty, 4, "bayer");
expect(out.data[3]).toBe(0); expect(out.data[3]).toBe(0);
@@ -75,7 +75,7 @@ describe("quantizeImage / ditherImage", () => {
}); });
describe("mapToNearest", () => { describe("mapToNearest", () => {
it("маппинг на ближайший из списка", () => { it("maps to nearest from list", () => {
const img = makeImage(2, 1, [ const img = makeImage(2, 1, [
[10, 10, 10, 255], [10, 10, 10, 255],
[240, 240, 240, 255], [240, 240, 240, 255],
+5 -5
View File
@@ -25,7 +25,7 @@ function nearestIndex(palette: Rgb[], r: number, g: number, b: number): number {
return best; return best;
} }
/** Median-cut: делит корзину с наибольшим разбросом по самому широкому каналу до k корзин. */ /** Median-cut: splits the bucket with the largest spread along its widest channel until k buckets. */
export function medianCutPalette(img: PixelImage, k: number): Rgb[] { export function medianCutPalette(img: PixelImage, k: number): Rgb[] {
const maxColors = Math.max(2, Math.min(64, Math.round(k))); const maxColors = Math.max(2, Math.min(64, Math.round(k)));
const pixels: Rgb[] = []; const pixels: Rgb[] = [];
@@ -102,7 +102,7 @@ export interface QuantizeResult {
palette: string[]; palette: string[];
} }
/** Приводит изображение к k цветам: median-cut + ближайший цвет палитры. Прозрачные пиксели не трогаются. */ /** Reduces the image to k colors: median-cut + nearest palette color. Transparent pixels untouched. */
export function quantizeImage(img: PixelImage, k: number): QuantizeResult { export function quantizeImage(img: PixelImage, k: number): QuantizeResult {
const palette = medianCutPalette(img, k); const palette = medianCutPalette(img, k);
const out = createPixelImage(img.width, img.height); const out = createPixelImage(img.width, img.height);
@@ -122,7 +122,7 @@ export function quantizeImage(img: PixelImage, k: number): QuantizeResult {
return { image: out, palette: palette.map(rgbToHex) }; return { image: out, palette: palette.map(rgbToHex) };
} }
/** Маппинг каждого пикселя на ближайший цвет пользовательского списка. */ /** Maps each pixel to the nearest color of the user-provided list. */
export function mapToNearest( export function mapToNearest(
img: PixelImage, img: PixelImage,
paletteHexes: string[], paletteHexes: string[],
@@ -155,8 +155,8 @@ const BAYER_4 = [
export type DitherPattern = "floyd-steinberg" | "bayer"; export type DitherPattern = "floyd-steinberg" | "bayer";
/** /**
* Дизеринг к палитре из k цветов (median-cut) или к явно заданному списку hex. * Dithering to a k-color palette (median-cut) or an explicit hex list.
* floyd-steinberg — распространение ошибки; bayer — упорядоченный 4×4. * floyd-steinberg: error diffusion; bayer: ordered 4x4.
*/ */
export function ditherImage( export function ditherImage(
img: PixelImage, img: PixelImage,
+50 -21
View File
@@ -1,9 +1,16 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { boxTest, circleTest, renderShape, starTest, wavyTest } from "./shapes"; import {
boxTest,
circleTest,
renderShape,
renderShapeMask,
starTest,
wavyTest,
} from "./shapes";
import { makeImage } from "./test-helpers"; import { makeImage } from "./test-helpers";
describe("тесты фигур", () => { describe("shape predicates", () => {
it("круг: центр внутри, угол снаружи", () => { it("circle: center inside, corner outside", () => {
const t = circleTest(0.5); const t = circleTest(0.5);
expect(t(0, 0)).toBe(true); expect(t(0, 0)).toBe(true);
expect(t(0.49, 0)).toBe(true); expect(t(0.49, 0)).toBe(true);
@@ -11,46 +18,46 @@ describe("тесты фигур", () => {
expect(t(0.4, 0.4)).toBe(false); expect(t(0.4, 0.4)).toBe(false);
}); });
it("прямоугольник: полуоси независимы", () => { it("box: semi-axes independent", () => {
const t = boxTest(0.5, 0.25); const t = boxTest(0.5, 0.25);
expect(t(0.45, 0.2)).toBe(true); expect(t(0.45, 0.2)).toBe(true);
expect(t(0.2, 0.3)).toBe(false); expect(t(0.2, 0.3)).toBe(false);
}); });
it("звезда: луч внутри дальше впадины", () => { it("star: ray inside further than valley", () => {
const t = starTest(5, 0.5, 1, 0); const t = starTest(5, 0.5, 1, 0);
expect(t(0.9, 0)).toBe(true); // вдоль луча (θ=0) expect(t(0.9, 0)).toBe(true); // along the ray (theta=0)
const valleyAngle = Math.PI / 5; // середина между лучами const valleyAngle = Math.PI / 5; // midway between rays
expect(t(Math.cos(valleyAngle) * 0.8, Math.sin(valleyAngle) * 0.8)).toBe( expect(t(Math.cos(valleyAngle) * 0.8, Math.sin(valleyAngle) * 0.8)).toBe(
false, false,
); );
expect(t(0.4, 0)).toBe(true); // радиус впадин 0.5 — 0.4 внутри всегда expect(t(0.4, 0)).toBe(true); // valley radius 0.5 - 0.4 always inside
}); });
it("волна: фаза двигает границу", () => { it("wave: phase moves the edge", () => {
const a = wavyTest(0.5, 0.1, 6, 0); const a = wavyTest(0.5, 0.1, 6, 0);
const b = wavyTest(0.5, 0.1, 6, 180); const b = wavyTest(0.5, 0.1, 6, 180);
const deg = 15; // sin(6·15°)=1 → край 0.6; при фазе 180° край 0.4 const deg = 15; // sin(6*15deg)=1 -> edge 0.6; at phase 180 deg edge 0.4
const rad = (deg * Math.PI) / 180; const rad = (deg * Math.PI) / 180;
const px = 0.55; const px = 0.55;
expect(a(px * Math.cos(rad), px * Math.sin(rad))).toBe(true); // край 0.6 expect(a(px * Math.cos(rad), px * Math.sin(rad))).toBe(true); // edge 0.6
expect(b(px * Math.cos(rad), px * Math.sin(rad))).toBe(false); // край 0.4 expect(b(px * Math.cos(rad), px * Math.sin(rad))).toBe(false); // edge 0.4
}); });
}); });
describe("renderShape", () => { describe("renderShape", () => {
const img = makeImage(4, 2, new Array(8).fill([255, 255, 255, 255])); const img = makeImage(4, 2, new Array(8).fill([255, 255, 255, 255]));
it("внутри сохраняет пиксели, снаружи альфа 0", () => { it("inside keeps pixels, outside alpha 0", () => {
const out = renderShape(img, boxTest(0.5, 0.5)); const out = renderShape(img, boxTest(0.5, 0.5));
let opaque = 0; let opaque = 0;
for (let i = 3; i < out.data.length; i += 4) for (let i = 3; i < out.data.length; i += 4)
if (out.data[i] === 255) opaque++; if (out.data[i] === 255) opaque++;
expect(opaque).toBe(4); expect(opaque).toBe(4);
expect(out.data[4]).toBe(255); // RGB внутри фигуры сохранён expect(out.data[4]).toBe(255); // RGB inside shape preserved
}); });
it("смещение центра переносит маску", () => { it("center offset moves the mask", () => {
const shifted = renderShape( const shifted = renderShape(
makeImage(2, 2, new Array(4).fill([255, 255, 255, 255])), makeImage(2, 2, new Array(4).fill([255, 255, 255, 255])),
circleTest(0.7), circleTest(0.7),
@@ -60,9 +67,9 @@ describe("renderShape", () => {
expect(shifted.data[4 + 3]).toBe(255); expect(shifted.data[4 + 3]).toBe(255);
}); });
// Регрессия старого бага square-mask: полный размер без смещения обязан // Regression of old square-mask bug: full size without offset must
// сохранять квадратную картинку целиком. // keep the whole square image.
it("квадрат 100% без смещения сохраняет всё квадратное изображение", () => { it("100% box without offset keeps whole square image", () => {
const out = renderShape( const out = renderShape(
makeImage(4, 4, new Array(16).fill([255, 255, 255, 255])), makeImage(4, 4, new Array(16).fill([255, 255, 255, 255])),
boxTest(0.5, 0.5), boxTest(0.5, 0.5),
@@ -70,9 +77,9 @@ describe("renderShape", () => {
for (let i = 3; i < out.data.length; i += 4) expect(out.data[i]).toBe(255); for (let i = 3; i < out.data.length; i += 4) expect(out.data[i]).toBe(255);
}); });
// Смещение — доля свободного места: ±0.5 прижимает фигуру к краю при любом // Offset is a fraction of free space: +/-0.5 pins the shape to the edge
// её размере, фигура не уезжает за холст. // at any size; the shape never leaves the canvas.
it("максимальное смещение прижимает фигуру к краю", () => { it("max offset pins shape to edge", () => {
const out = renderShape( const out = renderShape(
makeImage(4, 4, new Array(16).fill([255, 255, 255, 255])), makeImage(4, 4, new Array(16).fill([255, 255, 255, 255])),
boxTest(0.25, 0.25), boxTest(0.25, 0.25),
@@ -88,3 +95,25 @@ describe("renderShape", () => {
expect(opaqueAt(2, 1)).toBe(false); expect(opaqueAt(2, 1)).toBe(false);
}); });
}); });
describe("renderShapeMask", () => {
it("white inside the shape, black outside, same geometry as renderShape", () => {
const shape = renderShape(
makeImage(5, 5, new Array(25).fill([200, 100, 50, 255])),
circleTest(0.4),
0,
0,
{ x: 0.4, y: 0.4 },
);
const mask = renderShapeMask(5, 5, circleTest(0.4), 0, 0, {
x: 0.4,
y: 0.4,
});
for (let i = 0; i < 25; i++) {
const shaped = shape.data[i * 4 + 3] === 255;
const v = mask.data[i * 4];
expect(v, `pixel ${i}`).toBe(shaped ? 255 : 0);
expect(mask.data[i * 4 + 3]).toBe(255);
}
});
});
+68 -16
View File
@@ -3,15 +3,15 @@ import { createPixelImage } from "./types";
export type ShapeTest = (nx: number, ny: number) => boolean; export type ShapeTest = (nx: number, ny: number) => boolean;
/** Полуразмер фигуры в нормированных координатах теста, по осям. */ /** Half-size of the shape in normalized test coordinates, per axis. */
export interface ShapeExtent { export interface ShapeExtent {
x: number; x: number;
y: number; y: number;
} }
/** /**
* Координаты теста: нормированные к половине меньшей стороны изображения, * Test coordinates: normalized to half of the image's smaller side,
* центр в (0,0), ось Y вниз как в пикселях. * center at (0,0), Y axis pointing down as in pixels.
*/ */
export function circleTest(radiusFrac: number): ShapeTest { export function circleTest(radiusFrac: number): ShapeTest {
@@ -22,7 +22,7 @@ export function boxTest(halfWFrac: number, halfHFrac: number): ShapeTest {
return (nx, ny) => Math.abs(nx) <= halfWFrac && Math.abs(ny) <= halfHFrac; return (nx, ny) => Math.abs(nx) <= halfWFrac && Math.abs(ny) <= halfHFrac;
} }
/** Звезда с points лучами; innerFrac — радиус впадин в долях внешнего радиуса. */ /** Star with `points` rays; innerFrac is the valley radius as a fraction of the outer radius. */
export function starTest( export function starTest(
points: number, points: number,
innerFrac: number, innerFrac: number,
@@ -36,13 +36,13 @@ export function starTest(
const r = Math.hypot(nx, ny); const r = Math.hypot(nx, ny);
if (r > outerFrac) return false; if (r > outerFrac) return false;
const t = ((theta * n) / (2 * Math.PI)) % 1; const t = ((theta * n) / (2 * Math.PI)) % 1;
const tri = Math.abs(t - Math.floor(t + 0.5)) * 2; // 0 на луче, 1 во впадине const tri = Math.abs(t - Math.floor(t + 0.5)) * 2; // 0 on a ray, 1 in a valley
const edge = outerFrac - (outerFrac - innerFrac) * tri; const edge = outerFrac - (outerFrac - innerFrac) * tri;
return r <= edge; return r <= edge;
}; };
} }
/** Волнистый круг: радиус модулируется синусом с частотой waves. */ /** Wavy circle: radius modulated by a sine of frequency `waves`. */
export function wavyTest( export function wavyTest(
baseFrac: number, baseFrac: number,
amplitudeFrac: number, amplitudeFrac: number,
@@ -59,11 +59,30 @@ export function wavyTest(
} }
/** /**
* Вырезает фигуру из изображения: внутри фигуры сохраняются исходные пиксели * Shared geometry: shape center in pixels from offset fractions and extent.
* (с их альфой), снаружи альфа обнуляется. Смещение задаётся в долях свободного * Offset is a fraction of free space per axis: +-0.5 pins the shape to the
* места по каждой оси: ±0.5 прижимает фигуру к краю независимо от её размера, * edge regardless of size. Free space derives from extent (half-size).
* поэтому фигура никогда не уезжает за холст. Свободное место считается от */
* extent (полуразмера фигуры); без extent смещение считается от точки. function shapeOrigin(
width: number,
height: number,
offsetXFrac: number,
offsetYFrac: number,
extent: ShapeExtent,
): { cx: number; cy: number; minDim: number } {
const minDim = Math.min(width, height);
const freeX = Math.max(0, width / (2 * minDim) - extent.x);
const freeY = Math.max(0, height / (2 * minDim) - extent.y);
return {
cx: width / 2 + offsetXFrac * 2 * freeX * minDim,
cy: height / 2 + offsetYFrac * 2 * freeY * minDim,
minDim,
};
}
/**
* Cuts a shape out of the image: source pixels kept inside, alpha zeroed
* outside.
*/ */
export function renderShape( export function renderShape(
img: PixelImage, img: PixelImage,
@@ -73,11 +92,13 @@ export function renderShape(
extent: ShapeExtent = { x: 0, y: 0 }, extent: ShapeExtent = { x: 0, y: 0 },
): PixelImage { ): PixelImage {
const out = createPixelImage(img.width, img.height); const out = createPixelImage(img.width, img.height);
const minDim = Math.min(img.width, img.height); const { cx, cy, minDim } = shapeOrigin(
const freeX = Math.max(0, img.width / (2 * minDim) - extent.x); img.width,
const freeY = Math.max(0, img.height / (2 * minDim) - extent.y); img.height,
const cx = img.width / 2 + offsetXFrac * 2 * freeX * minDim; offsetXFrac,
const cy = img.height / 2 + offsetYFrac * 2 * freeY * minDim; offsetYFrac,
extent,
);
for (let y = 0; y < img.height; y++) { for (let y = 0; y < img.height; y++) {
for (let x = 0; x < img.width; x++) { for (let x = 0; x < img.width; x++) {
const di = (y * img.width + x) * 4; const di = (y * img.width + x) * 4;
@@ -90,3 +111,34 @@ export function renderShape(
} }
return out; return out;
} }
/** B/w preview of the shape itself: white inside, black outside. */
export function renderShapeMask(
width: number,
height: number,
test: ShapeTest,
offsetXFrac = 0,
offsetYFrac = 0,
extent: ShapeExtent = { x: 0, y: 0 },
): PixelImage {
const out = createPixelImage(width, height);
const { cx, cy, minDim } = shapeOrigin(
width,
height,
offsetXFrac,
offsetYFrac,
extent,
);
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
const di = (y * width + x) * 4;
const inside = test((x + 0.5 - cx) / minDim, (y + 0.5 - cy) / minDim);
const v = inside ? 255 : 0;
out.data[di] = v;
out.data[di + 1] = v;
out.data[di + 2] = v;
out.data[di + 3] = 255;
}
}
return out;
}
+7 -7
View File
@@ -3,7 +3,7 @@ import { hexToPixels, pixelsToHex } from "./text";
import { makeImage } from "./test-helpers"; import { makeImage } from "./test-helpers";
describe("pixelsToHex", () => { describe("pixelsToHex", () => {
it("форматирует пиксели как rrggbbaa построчно", () => { it("formats pixels as rrggbbaa row by row", () => {
const out = pixelsToHex( const out = pixelsToHex(
makeImage(2, 2, [ makeImage(2, 2, [
[255, 0, 0, 255], [255, 0, 0, 255],
@@ -17,7 +17,7 @@ describe("pixelsToHex", () => {
}); });
describe("hexToPixels", () => { describe("hexToPixels", () => {
it("обратим к pixelsToHex", () => { it("inverse of pixelsToHex", () => {
const source = makeImage(3, 1, [ const source = makeImage(3, 1, [
[1, 2, 3, 4], [1, 2, 3, 4],
[250, 251, 252, 253], [250, 251, 252, 253],
@@ -26,7 +26,7 @@ describe("hexToPixels", () => {
expect(hexToPixels(pixelsToHex(source), 3)).toEqual(source); expect(hexToPixels(pixelsToHex(source), 3)).toEqual(source);
}); });
it("допускает произвольные переводы строк и регистр", () => { it("tolerates arbitrary newlines and case", () => {
const out = hexToPixels("FF0000FF\n\n00FF0080 00000080", 1); const out = hexToPixels("FF0000FF\n\n00FF0080 00000080", 1);
expect(out.width).toBe(1); expect(out.width).toBe(1);
expect(out.height).toBe(3); expect(out.height).toBe(3);
@@ -36,10 +36,10 @@ describe("hexToPixels", () => {
}); });
it.each([ it.each([
["ff0000", "битые токены"], ["ff0000", "broken tokens"],
["ff0000ff ff0000ff ff0000ff", "не делится на ширину"], ["ff0000ff ff0000ff ff0000ff", "not divisible by width"],
["", "пустой ввод"], ["", "empty input"],
])("бросает понятную ошибку: %s (%s)", (input) => { ])("throws clear error: %s (%s)", (input) => {
expect(() => hexToPixels(input, 2)).toThrow(); expect(() => hexToPixels(input, 2)).toThrow();
}); });
}); });
+14 -14
View File
@@ -4,7 +4,7 @@ import { anchorOrigin, tileGrid, wrapText } from "./textdraw";
const measure = (s: string) => s.length * 10; const measure = (s: string) => s.length * 10;
describe("anchorOrigin", () => { describe("anchorOrigin", () => {
it("углы и отступ считаются от краёв", () => { it("corners and padding measured from edges", () => {
expect(anchorOrigin("top-left", 100, 50, 400, 300, 30)).toEqual({ expect(anchorOrigin("top-left", 100, 50, 400, 300, 30)).toEqual({
x: 30, x: 30,
y: 30, y: 30,
@@ -23,7 +23,7 @@ describe("anchorOrigin", () => {
}); });
}); });
it("центрирование — ровно половина остатка", () => { it("centering - exactly half the remainder", () => {
expect(anchorOrigin("center", 100, 50, 401, 301, 0)).toEqual({ expect(anchorOrigin("center", 100, 50, 401, 301, 0)).toEqual({
x: 150.5, x: 150.5,
y: 125.5, y: 125.5,
@@ -40,28 +40,28 @@ describe("anchorOrigin", () => {
}); });
describe("wrapText", () => { describe("wrapText", () => {
it("жадно набирает строки в пределах ширины", () => { it("greedily fills lines within width", () => {
// measure: 10px за символ → строка ≤ 120px = 12 символов // measure: 10px per char -> line <= 120px = 12 chars
expect(wrapText("один два три четыре пять", 120, measure)).toEqual([ expect(wrapText("four two six banana pear", 120, measure)).toEqual([
"один два три", "four two six",
"четыре пять", "banana pear",
]); ]);
}); });
it("слово длиннее ширины уходит на отдельную строку целиком", () => { it("word longer than width goes on its own line", () => {
expect( expect(
wrapText("короткое сверхдлинноеслово без переносов", 90, measure), wrapText("giraffes superlongwordhere cat crocodile", 90, measure),
).toEqual(["короткое", "сверхдлинноеслово", "без", "переносов"]); ).toEqual(["giraffes", "superlongwordhere", "cat", "crocodile"]);
}); });
it("пустой и пробельный текст дают пустой массив", () => { it("empty and whitespace text give empty array", () => {
expect(wrapText("", 100, measure)).toEqual([]); expect(wrapText("", 100, measure)).toEqual([]);
expect(wrapText(" \n\t ", 100, measure)).toEqual([]); expect(wrapText(" \n\t ", 100, measure)).toEqual([]);
}); });
}); });
describe("tileGrid", () => { describe("tileGrid", () => {
it("стабильная сетка с шагом и центрированием", () => { it("stable grid with spacing and centering", () => {
const pts = tileGrid(200, 200, 0, 60, 60, 80, 24); const pts = tileGrid(200, 200, 0, 60, 60, 80, 24);
expect(pts.length).toBeGreaterThan(0); expect(pts.length).toBeGreaterThan(0);
const xs = new Set(pts.map((p) => p.x)); const xs = new Set(pts.map((p) => p.x));
@@ -70,13 +70,13 @@ describe("tileGrid", () => {
expect(ys.size).toBeGreaterThan(1); expect(ys.size).toBeGreaterThan(1);
}); });
it("кап защищает от гигантского количества плиток", () => { it("cap protects against huge tile count", () => {
const pts = tileGrid(4000, 4000, 45, 8, 8, 100, 40); const pts = tileGrid(4000, 4000, 45, 8, 8, 100, 40);
expect(pts.length).toBeLessThanOrEqual(2500); expect(pts.length).toBeLessThanOrEqual(2500);
expect(pts.length).toBeGreaterThan(0); expect(pts.length).toBeGreaterThan(0);
}); });
it("обычные входные данные не триггерят кап", () => { it("normal input does not trigger cap", () => {
const pts = tileGrid(800, 600, 30, 140, 90, 160, 40); const pts = tileGrid(800, 600, 30, 140, 90, 160, 40);
expect(pts.length).toBeLessThanOrEqual(2500); expect(pts.length).toBeLessThanOrEqual(2500);
expect(pts.length).toBeGreaterThan(4); expect(pts.length).toBeGreaterThan(4);
+7 -8
View File
@@ -10,8 +10,8 @@ export type Position9 =
| "bottom-right"; | "bottom-right";
/** /**
* Левый верхний угол контента размером contentW×contentH при размещении * Top-left corner of content sized contentW x contentH placed on a cw x ch
* на холсте cw×ch с отступом margin согласно 9-позиционной сетке. * canvas with margin, per the 9-position grid.
*/ */
export function anchorOrigin( export function anchorOrigin(
position: Position9, position: Position9,
@@ -47,8 +47,8 @@ export function anchorOrigin(
} }
/** /**
* Жадный перенос текста по словам под ширину maxWidth. * Greedy word-wrap of text to maxWidth.
* measure — инъекция измерителя ширины строки. Пустой текст → пустой массив строк. * measure is an injected line-width meter. Empty text -> empty line array.
*/ */
export function wrapText( export function wrapText(
text: string, text: string,
@@ -80,10 +80,9 @@ export type TilePoint = { x: number; y: number };
const MAX_TILES = 2500; const MAX_TILES = 2500;
/** /**
* Сетка позиций плитки водяного знака в повернутой системе координат: * Grid of watermark tile positions in the rotated coordinate system:
* рисующий код один раз поворачивает контекст и ставит блоки в этих точках. * the drawing code rotates the context once and stamps blocks at these points.
* Шаги автоматически увеличиваются, если расчетное количество плиток * Steps grow when the tile count exceeds the cap, so big canvases stay fast.
* превышает кап — большие холсты не подвешивают страницу.
*/ */
export function tileGrid( export function tileGrid(
cw: number, cw: number,

Some files were not shown because too many files have changed in this diff Show More