mirror of
https://github.com/Ku6epXBOCTuK/easy-png-tools.git
synced 2026-11-14 17:38:19 +00:00
Compare commits
8
Commits
fce1b20e85
...
0cb94d06c2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0cb94d06c2 | ||
|
|
c22c6e5574 | ||
|
|
a51e224d02 | ||
|
|
4de8ec8190 | ||
|
|
8dcc263461 | ||
|
|
7361a82c18 | ||
|
|
0e8c2478a1 | ||
|
|
76039f7bf3 |
@@ -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.
|
||||
|
||||
- - -
|
||||
## [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
|
||||
#### Features
|
||||
- add og description\image\etc - ([31ace35](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/31ace359d7ff187cbe9ba968facc498e75e66b3d)) - Ku6epXBOCTuK
|
||||
|
||||
+12
-8
@@ -48,14 +48,17 @@ Coverage). Всё остальное в `core/**` — чистые функци
|
||||
Страница исполняет ровно один шаг: загрузил файл → применила инструмент →
|
||||
скачала результат. Отсутствуют:
|
||||
|
||||
- **Пайплайн / цепочки шагов** — модуля нет, состояния нет, localStorage для
|
||||
него нет. Модель страницы уже хранит список `steps`, но исполняет первый шаг,
|
||||
а `registry.test.ts` требует, чтобы шаг был ровно один: композиция формы из
|
||||
полей двух инструментов — отдельная работа, описанная как фаза 11
|
||||
`docs/roadmap.md`. Под неё зарезервированы
|
||||
`components/display/StepCard.svelte` (сейчас импортируется только витриной
|
||||
`/kit`) и ключи `chain.*`, `errors.badPipelineShape`,
|
||||
`errors.pipelineVersion`, `errors.noSteps`.
|
||||
- **Пайплайн / цепочки шагов** — реализованы: модуль шагов
|
||||
`web/src/lib/pipeline.svelte.ts` (createChain/parseChain/serializeSteps),
|
||||
именованные цепочки — `web/src/lib/chains.svelte.ts` (localStorage
|
||||
`chains:named`). Страница инструмента всегда открывается чистой; при первом
|
||||
структурном изменении (добавлен шаг, заменён первый инструмент) цепочка
|
||||
автоматически становится именованной (имя — дата/время) и маршрут бесшовно
|
||||
меняется на `/pipeline?id=<id>` (передача исходника через handoff-стор,
|
||||
replaceState). `/chains` — список цепочек (переименовать/удалить/открыть/
|
||||
создать). Легаси-автосейв `pipeline:<slug>` снят без миграции (ключи просто
|
||||
перестают читаться). Экспортный JSON-формат не устоялся и может меняться до
|
||||
фазы 11 `docs/roadmap.md`.
|
||||
- **Разделения страниц, которые делят один инструмент** — сводит все
|
||||
convert-страницы на один инструмент; задача в `docs/backlog.md`. Пока
|
||||
`registry.test.ts` не требует, чтобы на инструмент ссылались две страницы, а
|
||||
@@ -119,6 +122,7 @@ registry/{tools,pages}/*.ts
|
||||
| `result`? | `"image" \| "text" \| "verdict" \| "files"` — что отдаёт |
|
||||
| `domOnly`? | инструмент требует DOM (canvas); превью-executor запускает его напрямую |
|
||||
| `output`? | mime/ext/quality для скачивания (не у всех) |
|
||||
| `runMask`? | ч/б превью «что изменится»; тогл на тайле результата, в цепочку не идёт |
|
||||
| `run(ctx)` | единственный метод: `ctx` → `ToolResult` |
|
||||
|
||||
`Page` (записи в `registry/pages/<group>.ts`):
|
||||
|
||||
+46
-43
@@ -18,21 +18,6 @@
|
||||
- [ ] посмотреть реализацию flip animation svelte для анимации переключения
|
||||
режима aligned\normal
|
||||
- [ ] переделать визуал toggle компонента (которым режим переключается)
|
||||
- [ ] **Идея 1. Заменить инструмент шага без удаления** — оценка M. В заголовке
|
||||
карточки шага кнопка «поменять инструмент»: заменяет инструмент на месте
|
||||
(любой шаг, не только первый), остальные шаги и их параметры сохраняются,
|
||||
вход перечитывается. Открытый вопрос: что делать с несовместимыми входами
|
||||
(text-source ↔ file).
|
||||
- [ ] **Идея 3. Несколько цепочек: сохранение и загрузка** — оценка M.
|
||||
Именованные цепочки в localStorage (список, создать/переименовать/
|
||||
удалить), быстрое переключение. Формат экспортного JSON расширить
|
||||
опциональным полем имени; обратной совместимости со старыми выгрузками не
|
||||
делаем — формат не устоялся: он появится вместе с фазой 11
|
||||
`docs/roadmap.md` и до её старта может измениться. Плюс отдельная страница
|
||||
со списком всех цепочек: удалить, переименовать, добавить новую,
|
||||
«использовать» (переход на страницу инструмента с этой цепочкой). На
|
||||
странице — упрощённый вид: только список инструментов цепочки, без
|
||||
реального выполнения и параметров.
|
||||
- [ ] **Идея 4. Batch-обработка архивов выбранным chain** — оценка L. Вход —
|
||||
zip-архив картинок (распаковка в браузере), прогон текущего или выбранного
|
||||
сохранённого chain по каждому файлу, сборка результата обратно в zip для
|
||||
@@ -86,15 +71,42 @@
|
||||
- [ ] **Идея 6. Конвертация формата — НЕ отдельный инструмент, а выбор в кнопке
|
||||
Download** — оценка M/L. Решение и продуктовое обоснование:
|
||||
`docs/plan-seo.md` §0 и задача **S1f**; почему селектор не заменяет
|
||||
существующие convert-страницы — `docs/plan-seo.md` §5. Механическая часть
|
||||
уже есть: `Tool.output` (`OutputFormat`: mime/ext/qualityParamId) и
|
||||
кодирование по нему в `SchemaToolView.download()` —
|
||||
`docs/architecture.md`, раздел 3. Что осталось сверх S1f: - экспорт с
|
||||
лимитом размера → «сжатие до целевого КБ»: для lossy это quality в
|
||||
селекторе формата, для PNG — бинарный поиск по k из квантования. Нужна ли
|
||||
отдельная страница под этот интент — открытый вопрос с вордстатом, имя не
|
||||
выбрано (`docs/plan-seo.md` §5). Проработать вместе с достройкой
|
||||
download-флоу.
|
||||
существующие convert-страницы — `docs/plan-seo.md` §5. Селектор формата и
|
||||
качества в Download уже есть; «сжатие до целевого КБ» реализовано
|
||||
(`fitWithinBytes` в `web/src/lib/core/io.ts`: бинарный поиск по quality
|
||||
для lossy, по k квантования для PNG; для BMP — предупреждение, что лимит
|
||||
не применим). Что осталось: нужна ли отдельная страница под интент «сжать
|
||||
до N КБ» — открытый вопрос с вордстатом, имя не выбрано
|
||||
(`docs/plan-seo.md` §5). Проработать вместе с достройкой download-флоу.
|
||||
- [ ] **S1g под вопросом** — исходная формулировка (`docs/plan-seo.md` §4): на
|
||||
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` (водяной знак картинкой)** —
|
||||
оценка M. Нужна overlay-механика: второй источник изображения
|
||||
(getOverlay/store), параметры scale/opacity/position/margin/aspect.
|
||||
@@ -119,24 +131,21 @@
|
||||
отдельное решение о формате.
|
||||
- [ ] **Идея 12. HARD-серия** (glitch, barcode, signature-extract,
|
||||
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 и качество
|
||||
|
||||
- [ ] split to parts - посмотреть вариант с toggle component - может будет
|
||||
удобнее чем sectioned buttons
|
||||
- [ ] чип «Маска» (`resultCard.maskToggle` на тайлах результата) — попробовать
|
||||
заменить на Toggle-компонент, когда тот будет доделан (см. пункт про
|
||||
визуал toggle в «Pipeline»): возможно, будет удобнее и понятнее
|
||||
- [ ] маска подгружается с задержкой при нажатии на чип «Маска» — сейчас
|
||||
`runMask` считается только для включённых шагов (эффект в
|
||||
`SchemaToolView.svelte`). Альтернатива: считать маску всегда вместе с
|
||||
результатом, по нажатию просто показывать другую картинку. Оценить
|
||||
достоинства/недостатки: отклик мгновенный, но лишний проход по пикселям на
|
||||
каждый ран каждого шага с `runMask` (на больших изображениях — заметная
|
||||
цена за функцию, которую могут не открыть)
|
||||
- [ ] неудобно настраивать ползунками, некоторые инструменты удобнее настроить
|
||||
через drag-n-drop. звучит сложно, но надо придумать процесс
|
||||
- [ ] fav: текущий ui слишком дерганный — при клике на звёздочку элементы
|
||||
@@ -176,8 +185,6 @@
|
||||
в дизайне, а где должен быть 0
|
||||
- [ ] Component-тесты `SchemaToolView`/`SchemaPreview` требуют отдельного
|
||||
решения: node Vitest не имеет DOM/canvas, а e2e уже покрывает поведение.
|
||||
- [ ] Ошибка декодирования изображения из `createImageBitmap` пока показывается
|
||||
сырым сообщением браузера; нужен `ToolError`-маппер и i18n-ключ.
|
||||
- [ ] **Идея 13. Контраст текста на акцентных кнопках — частично закрыт**. Тона
|
||||
`--color-main`/`--color-accent` подняты под текущий порог
|
||||
`palette.test.ts`; targeted-тест проходит. Light-тема всё ещё ниже AA 4.5
|
||||
@@ -210,10 +217,6 @@
|
||||
`tool-flows.spec.ts`, `schema-tool-strings.test.ts`)
|
||||
- [ ] можно ли в тестах получать i18n ключ вместо текста? можно ли написать lint
|
||||
правило такое (запретить toContain без i18n)
|
||||
- [ ] убрать в тестах все нестандартные символы `→`, длинные тире, русский язык
|
||||
и т.д., сделать lint правило
|
||||
- [ ] комментарии на английском, lint правило
|
||||
- [ ] уменьшить количество и многословность комментариев (lint правило?)
|
||||
|
||||
## SEO/GEO
|
||||
|
||||
|
||||
+11
-2
@@ -186,7 +186,7 @@
|
||||
`+layout.svelte`), а не литералом `/og/…`: под `BASE_PATH` корневой путь
|
||||
ломается. Домен для этого не нужен; при деплое на собственном домене
|
||||
OG-картинки будут абсолютными и корректными.
|
||||
- [ ] **[S1f]** Download-кнопка с селектором формата PNG/JPG/WebP/BMP (+quality
|
||||
- [x] **[S1f]** Download-кнопка с селектором формата PNG/JPG/WebP/BMP (+quality
|
||||
для lossy) для всех инструментов — механика `Tool.output` уже есть
|
||||
(`docs/backlog.md`, «Идеи» №6, UX-райз «Download»). Продуктовая основа
|
||||
«image tools»-позиционирования: без неё сайт остаётся PNG-сайтом. Вести
|
||||
@@ -197,7 +197,9 @@
|
||||
что селектор формата его не покрывает.
|
||||
- [ ] **[S1g]** Поведение конвертеров после S1f: не предлагать в меню форматов
|
||||
формат исходника (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` остаются самостоятельными лендингами
|
||||
со своим контентом (§5).
|
||||
|
||||
@@ -313,6 +315,13 @@
|
||||
> смыслу: `round-corners-png` лежит в `alpha`, `add-text-png` и
|
||||
> `watermark-tile-png` — в `text`.
|
||||
|
||||
> **Открытый вопрос по интенту «сжать до N КБ».** Лимит размера теперь живёт в
|
||||
> кнопке Download (`fitWithinBytes` в `web/src/lib/core/io.ts`); интенты вида
|
||||
> «compress png to 500kb / 1mb» — high-volume и лимит-ориентированные, селектор
|
||||
> в Download их не закрывает как посадочная. Решение (отдельная страница / приём
|
||||
> в контенте существующих / ничего) требует вордстата; имя страницы не выбрано.
|
||||
> Прорабатывать вместе с вопросом «из формата в PNG» выше.
|
||||
|
||||
> **Открытый вопрос по интентам «из формата в PNG».** У конкурента есть
|
||||
> `jpg-to-png`, `webp-to-png`, `gif-to-png` как отдельные страницы; у нас таких
|
||||
> инструментов нет и создавать их не хочется — загрузка файла уже принимает
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "easy-png-tools",
|
||||
"version": "0.4.0",
|
||||
"version": "0.5.0",
|
||||
"description": "",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
|
||||
@@ -59,7 +59,7 @@ function findChunk(png: Buffer, type: string): number {
|
||||
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(
|
||||
width: number,
|
||||
height: number,
|
||||
@@ -279,7 +279,7 @@ export const svgMarkup =
|
||||
const PIXEL_BYTES = (r: number, g: number, b: number, a: number): string =>
|
||||
`${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(
|
||||
count: number,
|
||||
rgba: [number, number, number, number],
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -76,8 +76,8 @@ export async function renderText(page: Page, value: string): 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();
|
||||
await expect(input).toBeAttached();
|
||||
await input.setInputFiles({
|
||||
@@ -117,7 +117,7 @@ export async function uploadViaDrop(
|
||||
}
|
||||
|
||||
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)?$/ });
|
||||
await expect(button).toBeVisible();
|
||||
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> {
|
||||
expect(sink.errors, "нет console/page errors").toEqual([]);
|
||||
expect(sink.errors, "no console/page errors").toEqual([]);
|
||||
}
|
||||
|
||||
+10
-7
@@ -1,23 +1,26 @@
|
||||
import { expect, test } from "playwright/test";
|
||||
import { pixelRow } from "./helpers/fixtures";
|
||||
import { ruPageTitle } from "./helpers/i18n";
|
||||
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,
|
||||
}) => {
|
||||
await useEnglish(page);
|
||||
await page.goto("/list-tools");
|
||||
const input = page.getByRole("textbox");
|
||||
const flipCard = toolLink(page, "flip-png");
|
||||
await input.fill("отразить");
|
||||
await input.fill(flipTitleRu.split(" ")[0].toLowerCase());
|
||||
await expect(flipCard).toContainText("Flip PNG");
|
||||
await page.getByRole("button", { name: "RU", exact: true }).click();
|
||||
await expect(flipCard).toContainText("Отразить PNG");
|
||||
await expect(flipCard).toContainText(flipTitleRu);
|
||||
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,
|
||||
}) => {
|
||||
await openTool(page, "resize-png");
|
||||
@@ -25,13 +28,13 @@ test("RU/EN: переключение переводит заголовок ин
|
||||
const enBtn = page.getByRole("button", { name: "EN", exact: true });
|
||||
const heading = page.getByRole("heading", { level: 1 });
|
||||
await ruBtn.click();
|
||||
await expect(heading).toHaveText("Изменить размер PNG");
|
||||
await expect(heading).toHaveText(ruPageTitle("resize-png"));
|
||||
await expect(ruBtn).toHaveAttribute("aria-pressed", "true");
|
||||
await enBtn.click();
|
||||
await expect(heading).toHaveText("Resize PNG");
|
||||
});
|
||||
|
||||
test("ошибка run'а локализуется и переводится при смене языка", async ({
|
||||
test("run error is localized and translated on language switch", async ({
|
||||
page,
|
||||
}) => {
|
||||
await openTool(page, "bytes-to-png");
|
||||
|
||||
@@ -56,8 +56,8 @@ const decodeContracts: readonly DecodeContract[] = [
|
||||
{ file: sixteenBitPng, rejectedIn: [] },
|
||||
];
|
||||
|
||||
// На этих файлах createImageBitmap в webkit не завершается: ни результата, ни
|
||||
// ошибки — контракт «accept или reject» проверить нельзя.
|
||||
// On these files createImageBitmap in webkit never settles: no result and no
|
||||
// error, so the "accept or reject" contract cannot be verified.
|
||||
const webkitHangs = new Set([
|
||||
"truncated-header.png",
|
||||
"crc-bad-ancillary.png",
|
||||
@@ -72,7 +72,7 @@ test.describe("PNG special fixtures", () => {
|
||||
}) => {
|
||||
test.fixme(
|
||||
browserName === "webkit" && webkitHangs.has(file.name),
|
||||
"webkit createImageBitmap зависает на этом файле",
|
||||
"webkit createImageBitmap hangs on this file",
|
||||
);
|
||||
if (rejectedIn.includes(browserName)) {
|
||||
await expectRejected(page, file);
|
||||
|
||||
@@ -34,7 +34,7 @@ for (const [id, input] of [
|
||||
["data-uri-to-png", `data:image/png;base64,${tinyBase64}`],
|
||||
["svg-to-png", svgMarkup],
|
||||
] as const) {
|
||||
test(`text input → ${id} produces result image`, async ({ page }) => {
|
||||
test(`text input -> ${id} produces result image`, async ({ page }) => {
|
||||
const sink = trackErrors(page);
|
||||
await openTool(page, id);
|
||||
await expect(async () => {
|
||||
|
||||
@@ -46,7 +46,7 @@ test("resize-png keeps user-set size when another image is uploaded", async ({
|
||||
await uploadImage(page, opaquePng);
|
||||
const width = page.getByRole("spinbutton", { name: "Width" });
|
||||
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(
|
||||
"19",
|
||||
);
|
||||
@@ -157,7 +157,7 @@ test("download format selector: JPG warns about alpha and produces JPEG", async
|
||||
await expect(
|
||||
page.getByRole("status").filter({ hasText: /transparency/ }),
|
||||
).toBeVisible();
|
||||
// у JPG в dropdown есть настройка качества
|
||||
// JPG has a quality setting in the dropdown
|
||||
await rangeInput(page, "Quality").fill("10");
|
||||
const { name, bytes } = await downloadResultBytes(page);
|
||||
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) => {
|
||||
// newContext() не наследует use.baseURL из конфига — пробрасываем явно,
|
||||
// иначе относительный goto() в helpers не резолвится.
|
||||
// newContext() does not inherit use.baseURL from the config - pass it
|
||||
// explicitly, otherwise the relative goto() in helpers does not resolve.
|
||||
const context = await browser.newContext({
|
||||
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(resultImage(page)).toBeVisible();
|
||||
// цепочка переживает перезагрузку (localStorage)
|
||||
// the chain survives a reload (localStorage)
|
||||
await page.reload();
|
||||
await expect(stepTwo).toBeVisible();
|
||||
await expectNoErrorAlert(page);
|
||||
@@ -295,8 +295,8 @@ test("chain: remove step returns to a single tool", async ({ page }) => {
|
||||
exact: true,
|
||||
});
|
||||
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" });
|
||||
await card.getByLabel("Remove step").click();
|
||||
await expect(stepTwo).toHaveCount(0);
|
||||
|
||||
+10
-10
@@ -15,7 +15,7 @@ import {
|
||||
type Kind = "image" | "text-out" | "verdict";
|
||||
|
||||
const CASES: { id: string; kind: Kind; file?: SourceFile }[] = [
|
||||
// ── convert ────────────────────────────────────────────────
|
||||
// -- convert
|
||||
{ id: "convert-png-to-jpg", kind: "image" },
|
||||
{ id: "convert-png-to-webp", 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-bytes", kind: "text-out" },
|
||||
{ id: "png-to-rgb-values", kind: "text-out" },
|
||||
// ── alpha ──────────────────────────────────────────────────
|
||||
// -- alpha
|
||||
{ id: "remove-background-png", kind: "image", file: transparentPng },
|
||||
{ id: "remove-color-from-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: "square-mask-png", kind: "image", file: transparentPng },
|
||||
{ id: "star-mask-png", kind: "image", file: transparentPng },
|
||||
// ── color ──────────────────────────────────────────────────
|
||||
// -- color
|
||||
{ id: "grayscale-png", kind: "image" },
|
||||
{ id: "invert-colors-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-ycbcr", kind: "image" },
|
||||
{ id: "png-to-lab", kind: "image" },
|
||||
// ── geometry ───────────────────────────────────────────────
|
||||
// -- geometry
|
||||
{ id: "flip-png", kind: "image" },
|
||||
{ id: "rotate-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: "symmetric-copy-png", kind: "image" },
|
||||
{ id: "shift-png", kind: "image" },
|
||||
// ── filters ────────────────────────────────────────────────
|
||||
// -- filters
|
||||
{ id: "blur-png", kind: "image" },
|
||||
{ id: "sharpen-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: "randomize-pixels-png", kind: "image" },
|
||||
{ id: "jpeg-artifacts-png", kind: "image" },
|
||||
// ── text ───────────────────────────────────────────────────
|
||||
// -- text
|
||||
{ id: "add-text-png", kind: "image" },
|
||||
{ id: "date-stamp-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: "show-transparent-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-color-pixels-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-grayscale", 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 openTool(page, id);
|
||||
await uploadImage(page, fixture ?? opaquePng);
|
||||
|
||||
@@ -131,6 +131,34 @@ report-only (`warn`), baseline — 0 срабатываний.
|
||||
- Тесты: `__tests__/no-hardcoded-user-text.test.ts` — `RuleTester` на
|
||||
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` — единственные две
|
||||
|
||||
@@ -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(", "),
|
||||
},
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -1,18 +1,11 @@
|
||||
/**
|
||||
* Local ESLint plugin "conventions".
|
||||
*
|
||||
* Cross-cutting code conventions that the recommended rule sets don't enforce
|
||||
* (and plain @typescript-eslint rules cannot express in one shot):
|
||||
*
|
||||
* - interface-props: Svelte 5 props always go through a local `interface
|
||||
* Props` + `let { ... }: Props = $props()` (no inline generics, no inline
|
||||
* type imports, no untyped destructuring);
|
||||
* - no-string-union-alias: string-literal union aliases (`type Kind = 'a' |
|
||||
* 'b'`) are banned in favor of a single `as const` object + indexed access,
|
||||
* so literal sets live in exactly one place.
|
||||
*
|
||||
* Rules are AST-only (no filesystem), see the tests in eslint-plugins/__tests__.
|
||||
* Local ESLint plugin "conventions": cross-cutting code conventions the
|
||||
* recommended rule sets do not enforce. Rule details:
|
||||
* web/eslint-plugins/README.md.
|
||||
*/
|
||||
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 noStringUnionAlias from "./no-string-union-alias.js";
|
||||
|
||||
@@ -22,6 +15,9 @@ export default {
|
||||
version: "0.1.0",
|
||||
},
|
||||
rules: {
|
||||
"ascii-only": asciiOnly,
|
||||
"comment-format": commentFormat,
|
||||
"comments-english": commentsEnglish,
|
||||
"interface-props": interfaceProps,
|
||||
"no-string-union-alias": noStringUnionAlias,
|
||||
},
|
||||
|
||||
@@ -1,28 +1,8 @@
|
||||
// Rule: SVELTE 5 PROPS MUST USE A LOCAL `interface Props`.
|
||||
//
|
||||
// Convention (AGENTS.md): every typed props of a component is described by a
|
||||
// local `interface Props`, and the props are destructured with the annotation:
|
||||
//
|
||||
// interface Props {
|
||||
// label: string;
|
||||
// accent?: boolean;
|
||||
// children?: Snippet;
|
||||
// }
|
||||
// let { label, accent = false, children }: Props = $props();
|
||||
//
|
||||
// Banned instead:
|
||||
// - the inline generic `$props<{ ... }>()` — hard to read and splits the
|
||||
// type away from the file structure;
|
||||
// - untyped destructuring `let { ... } = $props()`;
|
||||
// - binding the whole props object (`const props = $props()`);
|
||||
// - any props type name other than the local `interface Props`.
|
||||
//
|
||||
// Inline `import('...')` type queries are NOT checked here: they are already
|
||||
// banned by @typescript-eslint/consistent-type-imports.
|
||||
//
|
||||
// The check is purely syntactic (AST-level). Svelte's runes model guarantees
|
||||
// $props() only exists in instance <script> blocks, so no scope/type info is
|
||||
// needed. Interfaces are collected from every <script> (module + instance).
|
||||
// Rule: SVELTE 5 PROPS MUST USE A LOCAL `interface Props` (AGENTS.md).
|
||||
// Banned: the inline generic `$props<T>()`, untyped destructuring, binding
|
||||
// the whole `$props()` object, non-local props type names. Purely syntactic
|
||||
// check; inline import('...') types are already covered by
|
||||
// @typescript-eslint/consistent-type-imports.
|
||||
|
||||
import { isInsideScriptElement } from "./utils.js";
|
||||
|
||||
|
||||
@@ -1,16 +1,7 @@
|
||||
// Rule: NO STRING-LITERAL UNION TYPE ALIASES.
|
||||
//
|
||||
// A type alias whose members are all string literals (`type Kind = 'a' | 'b'`)
|
||||
// duplicates the literal set: the same set is re-typed in many places, and new
|
||||
// "derived" aliases (`type Kind2 = 'a' | 'c'`) appear that drift from the
|
||||
// source. Prefer a single `as const` object as the source of truth and derive
|
||||
// the type from it with indexed access.
|
||||
//
|
||||
// const KIND = { a: ..., b: ..., c: ... } as const;
|
||||
// type Kind = (typeof KIND)[keyof typeof KIND];
|
||||
//
|
||||
// Only TSTypeAliasDeclaration is checked — inline unions in parameter or
|
||||
// property types (one-off uses) are left alone.
|
||||
// Rule: NO STRING-LITERAL UNION TYPE ALIASES. A `type Kind = 'a' | 'b'`
|
||||
// alias duplicates the literal set across files and derived aliases drift
|
||||
// from the source; prefer one `as const` object + indexed access. Only
|
||||
// TSTypeAliasDeclaration is checked. Details: web/eslint-plugins/README.md.
|
||||
|
||||
/** True when the union and all its (possibly nested) members are string literals. */
|
||||
function allStringLiterals(/** @type {any} */ union) {
|
||||
|
||||
@@ -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)$/;
|
||||
|
||||
// =====================================================================
|
||||
// SHORTHAND properties that accept BOTH a color and a size. The browser
|
||||
// assigns their sub-properties by value type at runtime (length -> width,
|
||||
// color -> ...-color), so they cannot be category-checked positionally.
|
||||
// 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.
|
||||
// SHORTHANDS taking BOTH a color and a size: the browser assigns
|
||||
// sub-properties by value type, so no positional category check. Their
|
||||
// longhands are covered by SIZE_PROPS / COLOR_PROPS individually.
|
||||
// =====================================================================
|
||||
export const MIXED_PROPS =
|
||||
/^(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 =
|
||||
/^(transition|transition-duration|transition-delay|animation|animation-duration|animation-delay)$/;
|
||||
|
||||
// =====================================================================
|
||||
// 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 =
|
||||
/#[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.
|
||||
// A size property (padding, gap, font-size ...) must use a SIZE token
|
||||
// (--space-*, --size-*, --text-*, --radius-*, --bp-*, --z-*). A color property
|
||||
// (color, background, border-color ...) must use a COLOR token
|
||||
// (--color-*, --brand-*). Crossing categories (e.g. padding: var(--color-x))
|
||||
// is a sign the wrong token is being reused.
|
||||
// Rule: NO TOKEN-CATEGORY MISMATCH IN COMPONENTS. A size property must use
|
||||
// a SIZE token (--space-*, --size-*, ...), a color property a COLOR token
|
||||
// (--color-*, --brand-*). Crossing categories (padding: var(--color-x))
|
||||
// means the wrong token is being reused.
|
||||
|
||||
import {
|
||||
COLOR_PROPS,
|
||||
@@ -56,12 +54,10 @@ export default {
|
||||
const prop = decl.prop ?? "";
|
||||
const value = decl.value ?? "";
|
||||
|
||||
// Mixed shorthands (border, outline, text-decoration,
|
||||
// column-rule) legitimately take BOTH a size and a color.
|
||||
// The browser assigns sub-properties by value type at runtime,
|
||||
// not by position, so skip category checks for them.
|
||||
// Their longhands (border-width, border-color, ...) are still
|
||||
// covered individually.
|
||||
// Mixed shorthands (border, outline, ...) legitimately take
|
||||
// BOTH a size and a color: the browser assigns sub-properties
|
||||
// by value type, not by position, so skip category checks.
|
||||
// Their longhands are still covered individually.
|
||||
if (MIXED_PROPS.test(prop)) return;
|
||||
|
||||
const isSizeProp = SIZE_PROPS.test(prop);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Rule: NO HARDCODED COLORS / SIZES / DURATIONS / Z-INDEX IN COMPONENTS.
|
||||
// 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.
|
||||
|
||||
import {
|
||||
@@ -14,8 +14,7 @@ import {
|
||||
} from "./lists.js";
|
||||
import { getStyleNodeLoc, getStyleRoot } from "./style-context.js";
|
||||
|
||||
// Sub-rule: stripe var(...) bodies out of a value, so tokens inside are never
|
||||
// mistaken for literals.
|
||||
// Strip var(...) bodies so tokens inside are never mistaken for literals.
|
||||
const stripVars = (value) => value.replace(/var\([^)]*\)/g, "");
|
||||
|
||||
export default {
|
||||
@@ -64,16 +63,12 @@ export default {
|
||||
});
|
||||
};
|
||||
|
||||
// =====================================================================
|
||||
// 3) NO HARDCODED BREAKPOINTS IN @media
|
||||
// @media conditions must use --bp-* tokens, not raw lengths.
|
||||
// Regex: (max|min)-width followed by a NON-var(() number.
|
||||
// =====================================================================
|
||||
// --- NO HARDCODED BREAKPOINTS IN @media ---
|
||||
// (max|min)-width followed by a NON-var( number is a raw length.
|
||||
const BREAKPOINT_RE =
|
||||
/(?:(?:max|min)-width)\s*:\s*(?!var\()(\d+(?:\.\d+)?(?:px|rem|em))/gi;
|
||||
|
||||
// Custom properties do not resolve inside @media conditions, so any
|
||||
// var(--bp-*) inside @media is a latent bug — the block never applies.
|
||||
// Custom properties do not resolve in @media: var(--bp-*) there is a latent bug.
|
||||
const VAR_IN_MEDIA_RE = /var\(\s*(--[a-zA-Z0-9-]+)/g;
|
||||
|
||||
// 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.
|
||||
if (!COLOR_PROPS.test(prop) && !SIZE_PROPS.test(prop)) return;
|
||||
|
||||
// Sub-rule 2: sizes.
|
||||
// Fire only on a real remaining px/rem/em token — percentages
|
||||
// (width: 80%) and unitless values (line-height: 1.5) stay legal.
|
||||
// The only legal absolute length is a 0px reset line.
|
||||
// MIXED_PROPS (border/outline shorthands) are checked for their
|
||||
// SIZE half too — a border width must come from var(--size-*).
|
||||
// Sub-rule 2: sizes. Fire only on a real px/rem/em token:
|
||||
// percentages and unitless values stay legal, a 0px reset is
|
||||
// the only legal absolute length. MIXED_PROPS shorthands are
|
||||
// checked for their SIZE half too.
|
||||
if (SIZE_PROPS.test(prop) || MIXED_PROPS.test(prop)) {
|
||||
const withoutVars = stripVars(value);
|
||||
const reported = new Set();
|
||||
@@ -141,9 +134,8 @@ export default {
|
||||
}
|
||||
}
|
||||
|
||||
// Sub-rule 1: colors.
|
||||
// Remaining value must be only allowed keywords
|
||||
// (currentColor/transparent/inherit/none/0); otherwise a color
|
||||
// Sub-rule 1: colors. After stripping allowed keywords
|
||||
// (currentColor/transparent/inherit/none/0), any color
|
||||
// literal is reported.
|
||||
if (COLOR_PROPS.test(prop)) {
|
||||
const withoutVars = stripVars(value);
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
// Rule: NO DESIGN PRIMITIVE IN COMPONENT TOKEN DEFINITIONS (variant A).
|
||||
// A custom property may be DEFINED inside a component's <style> block, but its
|
||||
// value must not introduce a design primitive: a raw color literal (hex/rgb/
|
||||
// oklch/named) or an absolute size (px/rem/em). This keeps the design surface
|
||||
// (colors/sizes) a single source of truth in app.css, while still allowing
|
||||
// local DERIVED variables built from tokens: var(--...), calc(), unitless
|
||||
// ratios (--ratio: 1.5) — those are legitimate component-local state.
|
||||
// A custom property may be DEFINED in a component's <style>, but its value
|
||||
// must not introduce a design primitive (raw color, absolute size): the
|
||||
// design surface stays in app.css, while local DERIVED variables (var(),
|
||||
// calc(), unitless ratios) remain legitimate.
|
||||
|
||||
import { COLOR_LITERAL, FORBIDDEN_SIZE_TOKEN } from "./lists.js";
|
||||
import { getStyleNodeLoc, getStyleRoot } from "./style-context.js";
|
||||
@@ -57,7 +55,7 @@ export default {
|
||||
|
||||
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);
|
||||
if (colorHit) {
|
||||
report(decl, "tokenPrimitive", {
|
||||
@@ -67,7 +65,7 @@ export default {
|
||||
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);
|
||||
if (sizeHit) {
|
||||
report(decl, "tokenPrimitive", {
|
||||
|
||||
@@ -10,7 +10,7 @@ import { getStyleNodeLoc, getStyleRoot } from "./style-context.js";
|
||||
|
||||
// A custom property DEFINITION: "--x:" followed by a colon.
|
||||
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 definedCache = new Map();
|
||||
|
||||
@@ -1,30 +1,7 @@
|
||||
/**
|
||||
* Cross-locale dictionary linter (i18n/dict-consistency).
|
||||
*
|
||||
* Each locale dict in `lib/i18n/` is checked against ALL the others (not just
|
||||
* 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.
|
||||
* Cross-locale dictionary linter: each dict in lib/i18n/ is checked against
|
||||
* ALL others (key parity, empty values, placeholders), warn-only.
|
||||
* Full semantics and options: web/eslint-plugins/README.md.
|
||||
*/
|
||||
import fs from "node:fs";
|
||||
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.
|
||||
*/
|
||||
function findDictExport(ast) {
|
||||
@@ -109,7 +86,7 @@ function findLocalesList(ast) {
|
||||
|
||||
/**
|
||||
* 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) {
|
||||
if (!node) return false;
|
||||
@@ -148,10 +125,9 @@ function propKey(prop) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Flatten an exported dict object into `path -> { node, kind, value }`.
|
||||
* Computed properties are skipped; intermediate objects are recorded too, so a
|
||||
* missing whole section is reported once (children of a missing path are
|
||||
* skipped when reporting).
|
||||
* Flatten an exported dict into `path -> { node, kind, value }`. Computed
|
||||
* properties are skipped; intermediate objects are recorded too, so a
|
||||
* missing whole section is reported once.
|
||||
*/
|
||||
function buildKeyMap(objectNode) {
|
||||
const map = new Map();
|
||||
@@ -226,7 +202,7 @@ function readDir(dir) {
|
||||
for (const locale of locales) {
|
||||
const file = path.join(dir, `${locale}.ts`);
|
||||
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>`.
|
||||
dicts.push({ locale, map: new Map() });
|
||||
continue;
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
/**
|
||||
* Local ESLint plugin "i18n".
|
||||
*
|
||||
* Cross-locale dictionary hygiene (i18n/dict-consistency): every locale dict in
|
||||
* `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.
|
||||
* Local ESLint plugin "i18n": cross-locale dictionary hygiene
|
||||
* (dict-consistency) and no hardcoded user text in templates. Warn-only by
|
||||
* design. Rule details: web/eslint-plugins/README.md.
|
||||
*/
|
||||
import dictConsistency from "./dict-consistency.js";
|
||||
import noHardcodedUserText from "./no-hardcoded-user-text.js";
|
||||
|
||||
@@ -1,19 +1,7 @@
|
||||
// Rule: NO HARDCODED USER TEXT IN SVELTE TEMPLATES.
|
||||
//
|
||||
// Every user-visible string must come from the dictionaries via t(); a literal in
|
||||
// the template silently ships English to the RU locale. Warn-only by design
|
||||
// (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.
|
||||
// Rule: NO HARDCODED USER TEXT IN SVELTE TEMPLATES. Every user-visible
|
||||
// string must come from the dictionaries via t(); a literal silently ships
|
||||
// English to the RU locale. Warn-only report-only pilot. Exclusion list
|
||||
// (brand, units, registry strings, /kit): web/eslint-plugins/README.md.
|
||||
|
||||
import { USER_TEXT_ATTRS } from "./lists.js";
|
||||
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
/**
|
||||
* Local ESLint plugin "design-tokens".
|
||||
*
|
||||
* Goal: a single source of truth for design. In Svelte components, hardcoded
|
||||
* 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.
|
||||
* Local ESLint plugin "design-tokens": hardcoded colors/sizes are banned in
|
||||
* Svelte <style> blocks, everything comes from CSS tokens in app.css.
|
||||
* Rule details: web/eslint-plugins/README.md.
|
||||
*/
|
||||
import noCategoryMismatch from "./design-tokens/no-category-mismatch.js";
|
||||
import noHardcodedInSvelte from "./design-tokens/no-hardcoded-in-svelte.js";
|
||||
|
||||
+97
-38
@@ -8,28 +8,27 @@ import designTokens from "./eslint-plugins/index.js";
|
||||
import conventionsPlugin from "./eslint-plugins/conventions/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/**"];
|
||||
|
||||
// Инфраструктура линтинга: сами плагины, скрипты и конфиги. Это не
|
||||
// продуктовый код, но всё это влияет на гейты, поэтому к нему тоже
|
||||
// применяется базовый JS recommended. Полные TS/svelte-наборы здесь не
|
||||
// подключаются: файлы .mjs и .js проверяются только базовым набором.
|
||||
// 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
|
||||
// TS/svelte sets do not (.mjs/.js only).
|
||||
const toolingFiles = [
|
||||
"**/eslint-plugins/**/*.js",
|
||||
"**/scripts/**/*.mjs",
|
||||
"*.config.js",
|
||||
];
|
||||
|
||||
// Полные recommended-наборы — только на production-код (см. ниже, блок перед prettier).
|
||||
// Full recommended sets apply to production code only (block before prettier).
|
||||
const jsRecommended = Array.isArray(js.configs.recommended)
|
||||
? js.configs.recommended
|
||||
: [js.configs.recommended];
|
||||
const svelteRecommended = Array.isArray(svelte.configs["flat/recommended"])
|
||||
? svelte.configs["flat/recommended"]
|
||||
: [svelte.configs["flat/recommended"]];
|
||||
// Svelte-рекомендации применяем только к .svelte-файлам production-кода, иначе
|
||||
// svelte-eslint-parser "съедает" обычные .ts в тех же папках (напр. +page.ts).
|
||||
// Svelte recommended targets production .svelte files only, otherwise
|
||||
// svelte-eslint-parser swallows plain .ts in the same folders (+page.ts).
|
||||
const productionSvelte = [
|
||||
"**/src/lib/components/**/*.svelte",
|
||||
"**/src/routes/**/*.svelte",
|
||||
@@ -45,7 +44,7 @@ export default tseslint.config(
|
||||
"**/static/**",
|
||||
],
|
||||
},
|
||||
// Минимально на весь код: парсинг TS/Svelte + запрет инлайн-тип-импортов.
|
||||
// Minimum on all code: TS/Svelte parsing + ban on inline type imports.
|
||||
{
|
||||
files: ["**/*.ts", "**/*.svelte.ts", "**/*.svelte.js", "**/*.svelte"],
|
||||
languageOptions: {
|
||||
@@ -59,17 +58,18 @@ export default tseslint.config(
|
||||
"vitest.config.ts",
|
||||
"eslint.config.js",
|
||||
"playwright.config.ts",
|
||||
// e2e покрыты e2e/tsconfig.json.
|
||||
// Тесты и фикстуры кастомных линт-правил лежат вне src/ (не в
|
||||
// tsconfig), поэтому для типизированного парсинга резолвятся
|
||||
// через default-проект. Перечисляются точечно: `**` в
|
||||
// allowDefaultProject запрещён tseslint.
|
||||
// e2e are covered by e2e/tsconfig.json. Lint-rule tests and
|
||||
// fixtures live outside src/, so typed parsing resolves them
|
||||
// via the default project; tseslint bans `**` here.
|
||||
"eslint-plugins/__tests__/design-tokens.test.ts",
|
||||
"eslint-plugins/__tests__/interface-props.test.ts",
|
||||
"eslint-plugins/__tests__/no-string-union-alias.test.ts",
|
||||
"eslint-plugins/__tests__/helpers.ts",
|
||||
"eslint-plugins/__tests__/dict-consistency.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/en.ts",
|
||||
"eslint-plugins/__fixtures__/src/lib/i18n/ru.ts",
|
||||
@@ -90,8 +90,8 @@ export default tseslint.config(
|
||||
"@typescript-eslint": tseslint.plugin,
|
||||
},
|
||||
rules: {
|
||||
// Инлайн-тип-импорты (import('x').Y) запрещены — только
|
||||
// `import type { Y } from 'x'` наверху файла.
|
||||
// Inline type imports (import('x').Y) are banned; only
|
||||
// `import type { Y } from 'x'` at the top of the file.
|
||||
"@typescript-eslint/consistent-type-imports": [
|
||||
"error",
|
||||
{ prefer: "type-imports", fixStyle: "separate-type-imports" },
|
||||
@@ -116,8 +116,7 @@ export default tseslint.config(
|
||||
"design-tokens": designTokens,
|
||||
},
|
||||
},
|
||||
// Правило дизайн-токенов: запрет хардкода цветов/размеров в style-блоках.
|
||||
// Применяется к новому коду редизайна.
|
||||
// Design-token rules: no hardcoded colors/sizes in style blocks.
|
||||
{
|
||||
files: productionSvelte,
|
||||
plugins: {
|
||||
@@ -130,8 +129,8 @@ export default tseslint.config(
|
||||
"design-tokens/no-undefined-in-svelte": "error",
|
||||
},
|
||||
},
|
||||
// Конвенция Svelte 5: пропсы через локальный `interface Props` +
|
||||
// `let {...}: Props = $props()` (плагин conventions/interface-props).
|
||||
// Svelte 5 convention: props via a local `interface Props` +
|
||||
// `let {...}: Props = $props()` (conventions/interface-props).
|
||||
{
|
||||
files: productionSvelte,
|
||||
plugins: {
|
||||
@@ -141,8 +140,8 @@ export default tseslint.config(
|
||||
"conventions/interface-props": "error",
|
||||
},
|
||||
},
|
||||
// Запрет строковых union-алиасов в пользу `as const` объектов
|
||||
// (плагин conventions/no-string-union-alias). TS и Svelte-скрипты production-кода.
|
||||
// Ban string-literal union aliases in favor of `as const` objects
|
||||
// (conventions/no-string-union-alias).
|
||||
{
|
||||
files: productionCode,
|
||||
plugins: {
|
||||
@@ -152,11 +151,9 @@ export default tseslint.config(
|
||||
"conventions/no-string-union-alias": "error",
|
||||
},
|
||||
},
|
||||
// Кросс-языковой линтер словарей (плагин i18n/dict-consistency) и запрет
|
||||
// захардкоженного пользовательского текста (i18n/no-hardcoded-user-text).
|
||||
// Правила сообщают warning, а `lint:all` превращает новые warnings в
|
||||
// blocking через --max-warnings=0. Список локалей берётся из LOCALES в
|
||||
// lib/i18n/dict.ts; новые локали подхватываются автоматически.
|
||||
// Cross-locale dictionary linter (i18n/dict-consistency). Reports warn;
|
||||
// `lint:all` makes any new warning blocking via --max-warnings=0. The
|
||||
// locale list comes from LOCALES in lib/i18n/dict.ts.
|
||||
{
|
||||
files: ["**/src/lib/i18n/*.ts"],
|
||||
plugins: {
|
||||
@@ -173,9 +170,9 @@ export default tseslint.config(
|
||||
},
|
||||
},
|
||||
{
|
||||
// Хардкод пользовательского текста ловим и в components, и в routes:
|
||||
// `+page.svelte`/`+layout.svelte` — такой же production-UI, иначе
|
||||
// захардкоженная строка молча уедет в RU-локаль.
|
||||
// Hardcoded user text is caught in components and routes alike:
|
||||
// `+page.svelte` is production UI too, a hardcoded string silently
|
||||
// ships in the RU locale.
|
||||
files: productionSvelte,
|
||||
plugins: {
|
||||
i18n: i18nPlugin,
|
||||
@@ -184,21 +181,83 @@ export default tseslint.config(
|
||||
"i18n/no-hardcoded-user-text": [
|
||||
"warn",
|
||||
{
|
||||
// Название продукта — бренд, а не переводимый текст.
|
||||
// The product name is a brand, not translatable text.
|
||||
allowWords: ["easy-png-tools"],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
// Исключение для /kit — витрина компонентов с намеренно латинскими
|
||||
// подписями. Отдельный объект, а не негативный glob в `files` выше:
|
||||
// негативные паттерны не сужают область действия правила.
|
||||
// /kit exception: a showcase with intentionally Latin labels. Separate
|
||||
// object: negative globs in `files` do not narrow a rule's scope.
|
||||
files: ["**/src/routes/kit/**/*.svelte"],
|
||||
rules: {
|
||||
"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,
|
||||
languageOptions: {
|
||||
@@ -209,7 +268,7 @@ export default tseslint.config(
|
||||
},
|
||||
},
|
||||
},
|
||||
// Полные recommended-наборы — только на production-код.
|
||||
// Full recommended sets: production code only.
|
||||
...[
|
||||
...jsRecommended.map((cfg) => ({
|
||||
...cfg,
|
||||
@@ -229,14 +288,14 @@ export default tseslint.config(
|
||||
})),
|
||||
],
|
||||
|
||||
// В Svelte 5 пропсы деструктурируются через `let` (конвенция документации и
|
||||
// наш AGENTS.md), поэтому prefer-const на них — ложноположительный.
|
||||
// In Svelte 5 props are destructured via `let` (docs convention and our
|
||||
// AGENTS.md), so prefer-const is a false positive on them.
|
||||
{
|
||||
files: ["**/*.svelte"],
|
||||
rules: {
|
||||
"prefer-const": "off",
|
||||
},
|
||||
},
|
||||
// prettier — последним, чтобы гасить форматирующие правила из recommended.
|
||||
// prettier goes last to silence formatting rules from the recommended sets.
|
||||
prettier,
|
||||
);
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "web",
|
||||
"private": true,
|
||||
"version": "0.4.0",
|
||||
"version": "0.5.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
|
||||
@@ -44,9 +44,9 @@ export default defineConfig({
|
||||
{
|
||||
name: "webkit",
|
||||
testMatch: browserContractTests,
|
||||
// webkit под Windows заметно медленнее и под нагрузкой случайно
|
||||
// превышает любые таймауты (каждый прогон — разные тесты): запас по
|
||||
// таймаутам + retries против флака.
|
||||
// WebKit on Windows is noticeably slower and under load randomly
|
||||
// exceeds any timeout (different tests each run): timeout headroom
|
||||
// plus retries against flakiness.
|
||||
timeout: 120_000,
|
||||
expect: { timeout: 30_000 },
|
||||
retries: 2,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Генерирует static/og/default.png — дефолтная OG-карточка 1200×630.
|
||||
// Запуск: pnpm --dir web gen:og. Карточка рисуется в браузере (текст, иконки),
|
||||
// per-tool карточки — этим же harness позже (docs/plan-seo.md S1e).
|
||||
// Generates static/og/default.png: the default 1200x630 OG card.
|
||||
// Run: pnpm --dir web gen:og. The card is rendered in a browser (text,
|
||||
// icons); per-tool cards will reuse this harness (docs/plan-seo.md S1e).
|
||||
import { mkdirSync } from "node:fs";
|
||||
import { chromium } from "playwright";
|
||||
|
||||
|
||||
@@ -4,18 +4,10 @@ import { fileURLToPath } from "node:url";
|
||||
const WEB_DIR = fileURLToPath(new URL("../", import.meta.url));
|
||||
const node = process.execPath;
|
||||
|
||||
// Every lint layer of the redesign, in one command. Each step runs even if a
|
||||
// previous one fails; the exit code is nonzero if any did.
|
||||
//
|
||||
// Stream wiring: children run with stdio ["ignore", 1, 1] — both stdout and
|
||||
// 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.
|
||||
// Every lint layer in one command. Each step runs even if a previous one
|
||||
// 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
|
||||
// even to a non-TTY, hence the explicit --no-color there.
|
||||
const colorFlag = process.stdout.isTTY ? [] : ["--no-color"];
|
||||
|
||||
const bins = {
|
||||
|
||||
@@ -1,16 +1,7 @@
|
||||
// postcss-hct: PostCSS plugin that lets design tokens be authored as hct() and
|
||||
// emits plain sRGB hex colors.
|
||||
//
|
||||
// Supported syntax (Material Design 3 HCT; hue 0–360, chroma 0–~120, tone 0–100):
|
||||
//
|
||||
// --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.
|
||||
// postcss-hct: PostCSS plugin that lets design tokens be authored as hct()
|
||||
// (literal, `from var(...)` seed, channel math) and emits sRGB hex. `from`
|
||||
// seeds resolve from sibling custom properties (#hex, oklch(), rgb(), hct());
|
||||
// anything unresolvable is left as-is. HCT stays the single source of truth.
|
||||
|
||||
import { Color } from "@panmdaa/colors";
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Статический сервер для e2e (Playwright webServer). Раздаёт web/build
|
||||
// (adapter-static): /foo -> foo.html | foo/index.html, MIME по расширению.
|
||||
// Запуск: pnpm build && node scripts/serve-static.mjs --port 4173
|
||||
// Static server for e2e (Playwright webServer). Serves web/build
|
||||
// (adapter-static): /foo -> foo.html | foo/index.html, MIME by extension.
|
||||
// Run: pnpm build && node scripts/serve-static.mjs --port 4173
|
||||
import { createServer } from "node:http";
|
||||
import { readFileSync, statSync } from "node:fs";
|
||||
import { extname, join } from "node:path";
|
||||
@@ -56,7 +56,7 @@ function serve(req, res) {
|
||||
res.end(body);
|
||||
return;
|
||||
} catch {
|
||||
/* not found — try next candidate */
|
||||
/* not found - try next candidate */
|
||||
}
|
||||
}
|
||||
res.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" });
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
// 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) —
|
||||
// so the whole palette is computed through HCT channels and output as sRGB by
|
||||
// the postcss-hct plugin. Exceptions: the seed tokens --brand-main/--brand-alt
|
||||
// (any format allowed) and color-mix(...) (the only sanctioned way to blend
|
||||
// two tokens).
|
||||
// (literals and derived forms), so the whole palette is computed through HCT
|
||||
// and output as sRGB by postcss-hct. Exceptions: the seed tokens
|
||||
// --brand-main/--brand-alt and color-mix(...) (the only sanctioned blend).
|
||||
|
||||
import { isColorValue } from "./helpers.mjs";
|
||||
|
||||
|
||||
@@ -41,14 +41,14 @@ export function collectUsedTokens() {
|
||||
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.
|
||||
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;
|
||||
|
||||
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.
|
||||
export const isDerived = (value) => value.includes("var(");
|
||||
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
// 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
|
||||
// unused-token dust it is a defect, not a style choice.
|
||||
//
|
||||
// `var(--x, fallback)` is legal without --x (CSS custom property fallback), so
|
||||
// those references are exempt.
|
||||
// same file defines: a typo silently drops the declaration, so this is a
|
||||
// defect, not a style choice. `var(--x, fallback)` is legal without --x.
|
||||
|
||||
const VAR_REF_RE = /var\(\s*(--[\w-]+)\s*([,)])/g;
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// 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
|
||||
// so tests can pass a fixture instead of scanning src.
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ export type PreviewGroup = {
|
||||
};
|
||||
|
||||
/**
|
||||
* Preview-каталог: все страницы, сгруппированные по категориям.
|
||||
* Preview catalog: all pages grouped by category.
|
||||
*/
|
||||
export const PREVIEW_GROUPS: PreviewGroup[] = CATEGORY_IDS.map((category) => ({
|
||||
id: category,
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
/**
|
||||
* Категории инструментов для нового registry.
|
||||
*
|
||||
* Единая точка правды: тип `CategoryId` и порядок отображения в каталоге
|
||||
* выводятся из одного `as const`-объекта, чтобы не рассинхронизироваться.
|
||||
*
|
||||
* Человекочитаемые названия живут в словарях i18n: секция categories,
|
||||
* ключ = CategoryId.
|
||||
* Tool categories: `CategoryId` type and catalog display order derive from
|
||||
* one `as const` object to avoid drift. Human-readable names live in the
|
||||
* i18n dictionaries, section categories, key = CategoryId.
|
||||
*/
|
||||
export const CATEGORIES = {
|
||||
convert: "convert",
|
||||
@@ -20,5 +16,5 @@ export const 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[];
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -4,7 +4,7 @@
|
||||
interface Props {
|
||||
title: string;
|
||||
description: string;
|
||||
/** Путь страницы от корня роутера ("/", "/list-tools", "/tools/…"). */
|
||||
/** Page path from the router root ("/", "/list-tools", "/tools/..."). */
|
||||
path: string;
|
||||
}
|
||||
let { title, description, path }: Props = $props();
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
label: string;
|
||||
value: unknown;
|
||||
spec: FieldSpec;
|
||||
/** Размеры входа шага: потолок осей для maxFromSource-полей. */
|
||||
/** Step input dimensions: axis cap for maxFromSource fields. */
|
||||
sourceDims?: Dimension;
|
||||
onchange?: (value: Dimension, axis?: "width" | "height" | "both") => void;
|
||||
}
|
||||
@@ -51,7 +51,7 @@
|
||||
}
|
||||
|
||||
function applyPreset(n: number) {
|
||||
// Пресет задаёт обе оси явно — lockAspect его не пересчитывает.
|
||||
// Preset sets both axes explicitly; lockAspect does not recompute it.
|
||||
onchange?.({ width: n, height: n }, "both");
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
<nav class="nav">
|
||||
<a href={resolve("/")}>{t("header.home")}</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>
|
||||
</nav>
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
dims?: string;
|
||||
note?: string;
|
||||
noteTone?: "warning" | "info";
|
||||
actions?: Snippet;
|
||||
}
|
||||
let {
|
||||
label,
|
||||
@@ -26,6 +27,7 @@
|
||||
dims = undefined,
|
||||
note = undefined,
|
||||
noteTone = "warning",
|
||||
actions = undefined,
|
||||
}: Props = $props();
|
||||
</script>
|
||||
|
||||
@@ -44,6 +46,9 @@
|
||||
{/if}
|
||||
</span>
|
||||
<span class="tile-note">
|
||||
{#if actions}
|
||||
{@render actions()}
|
||||
{/if}
|
||||
{#if note}
|
||||
<Badge tone={noteTone} label={note} />
|
||||
{/if}
|
||||
|
||||
@@ -14,11 +14,13 @@
|
||||
running: boolean;
|
||||
format?: OutputMime;
|
||||
quality?: number;
|
||||
limitKb?: number;
|
||||
alphaLoss?: boolean;
|
||||
onupload: (file: File) => void;
|
||||
ondownload: () => void;
|
||||
onformat?: (mime: OutputMime) => void;
|
||||
onquality?: (value: number) => void;
|
||||
onlimit?: (kb: number | undefined) => void;
|
||||
}
|
||||
let {
|
||||
inputMode,
|
||||
@@ -27,11 +29,13 @@
|
||||
running,
|
||||
format = "image/png",
|
||||
quality = undefined,
|
||||
limitKb = undefined,
|
||||
alphaLoss = false,
|
||||
onupload,
|
||||
ondownload,
|
||||
onformat,
|
||||
onquality,
|
||||
onlimit,
|
||||
}: Props = $props();
|
||||
</script>
|
||||
|
||||
@@ -43,10 +47,12 @@
|
||||
<SchemaDownload
|
||||
{format}
|
||||
{quality}
|
||||
{limitKb}
|
||||
{running}
|
||||
{ondownload}
|
||||
onformat={(v) => onformat?.(v)}
|
||||
onquality={(v) => onquality?.(v)}
|
||||
onlimit={(v) => onlimit?.(v)}
|
||||
/>
|
||||
{:else if canDownload && resultKind === "files"}
|
||||
<DownloadButton
|
||||
|
||||
@@ -13,13 +13,23 @@
|
||||
interface Props {
|
||||
format: OutputMime;
|
||||
quality?: number;
|
||||
limitKb?: number;
|
||||
running: boolean;
|
||||
ondownload: () => void;
|
||||
onformat: (mime: OutputMime) => void;
|
||||
onquality: (value: number) => void;
|
||||
onlimit: (kb: number | undefined) => void;
|
||||
}
|
||||
let { format, quality, running, ondownload, onformat, onquality }: Props =
|
||||
$props();
|
||||
let {
|
||||
format,
|
||||
quality,
|
||||
limitKb,
|
||||
running,
|
||||
ondownload,
|
||||
onformat,
|
||||
onquality,
|
||||
onlimit,
|
||||
}: Props = $props();
|
||||
|
||||
const current = $derived(outputFormatByMime(format));
|
||||
const qualitySetting = $derived(current.settings?.quality);
|
||||
@@ -39,11 +49,16 @@
|
||||
let root = $state<HTMLDivElement | null>(null);
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
function handleLimit(e: Event) {
|
||||
const raw = (e.target as HTMLInputElement).valueAsNumber;
|
||||
onlimit(Number.isFinite(raw) && raw >= 1 ? Math.round(raw) : undefined);
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (!open) return;
|
||||
function onPointerDown(e: PointerEvent) {
|
||||
@@ -110,6 +125,24 @@
|
||||
/>
|
||||
</div>
|
||||
{/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>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -183,4 +216,37 @@
|
||||
padding-top: var(--space-m);
|
||||
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>
|
||||
|
||||
@@ -58,7 +58,7 @@
|
||||
schema: ToolSchema<Record<string, unknown>>;
|
||||
values: Record<string, unknown>;
|
||||
toolId: string;
|
||||
/** Размеры входа шага: для maxFromSource-полей (crop и т.п.). */
|
||||
/** Step input dimensions: for maxFromSource fields (crop, etc.). */
|
||||
sourceDims?: Dimension;
|
||||
onchange: (
|
||||
id: string,
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
import SchemaSourceTile from "./SchemaSourceTile.svelte";
|
||||
import PreviewTile from "./PreviewTile.svelte";
|
||||
import Toggle from "$lib/components/ui/Toggle.svelte";
|
||||
import ChipButton from "$lib/components/ui/ChipButton.svelte";
|
||||
import { toDataUrl } from "$lib/core/io";
|
||||
import type { PixelImage } from "$lib/core/types";
|
||||
import type { OutputMime } from "$lib/core/io";
|
||||
@@ -27,13 +28,21 @@
|
||||
textSource?: string;
|
||||
textResult?: string | null;
|
||||
textVars?: Record<string, string | number>;
|
||||
mask?: PixelImage | null;
|
||||
maskOn?: boolean;
|
||||
ontogglemask?: () => void;
|
||||
running?: boolean;
|
||||
error?: string;
|
||||
stepResults?: (PixelImage | null)[];
|
||||
stepMaskable?: boolean[];
|
||||
stepMaskOn?: boolean[];
|
||||
stepMasks?: (PixelImage | null)[];
|
||||
ontogglestepmask?: (i: number) => void;
|
||||
aligned?: boolean;
|
||||
ontogglealign?: () => void;
|
||||
format?: OutputMime;
|
||||
quality?: number;
|
||||
limitKb?: number;
|
||||
alphaLoss?: boolean;
|
||||
onupload: (file: File) => void;
|
||||
ontextsource?: (text: string) => void;
|
||||
@@ -43,6 +52,7 @@
|
||||
ondownload: () => void;
|
||||
onformat?: (mime: OutputMime) => void;
|
||||
onquality?: (value: number) => void;
|
||||
onlimit?: (kb: number | undefined) => void;
|
||||
}
|
||||
let {
|
||||
inputMode,
|
||||
@@ -55,13 +65,21 @@
|
||||
textSource = "",
|
||||
textResult = null,
|
||||
textVars = undefined,
|
||||
mask = null,
|
||||
maskOn = false,
|
||||
ontogglemask,
|
||||
running = false,
|
||||
error = "",
|
||||
stepResults = [],
|
||||
stepMaskable = [],
|
||||
stepMaskOn = [],
|
||||
stepMasks = [],
|
||||
ontogglestepmask,
|
||||
aligned = false,
|
||||
ontogglealign,
|
||||
format = "image/png",
|
||||
quality = undefined,
|
||||
limitKb = undefined,
|
||||
alphaLoss = false,
|
||||
onupload,
|
||||
ontextsource,
|
||||
@@ -71,6 +89,7 @@
|
||||
ondownload,
|
||||
onformat,
|
||||
onquality,
|
||||
onlimit,
|
||||
}: Props = $props();
|
||||
|
||||
const hasResult = $derived(
|
||||
@@ -97,11 +116,13 @@
|
||||
{running}
|
||||
{format}
|
||||
{quality}
|
||||
{limitKb}
|
||||
{alphaLoss}
|
||||
{onupload}
|
||||
{ondownload}
|
||||
{onformat}
|
||||
{onquality}
|
||||
{onlimit}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -126,7 +147,21 @@
|
||||
viewMode="image"
|
||||
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>
|
||||
{/if}
|
||||
{/each}
|
||||
@@ -139,6 +174,9 @@
|
||||
{textVars}
|
||||
{toolId}
|
||||
{running}
|
||||
{mask}
|
||||
{maskOn}
|
||||
{ontogglemask}
|
||||
oncopy={oncopytext}
|
||||
{ondownloadtxt}
|
||||
/>
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
import { t } from "$lib/i18n/t";
|
||||
import type { FileResult, ResultKind, ResultNote } from "$lib/registry";
|
||||
import { Check } from "@lucide/svelte";
|
||||
import ChipButton from "$lib/components/ui/ChipButton.svelte";
|
||||
import SchemaTextResult from "./SchemaTextResult.svelte";
|
||||
import PreviewTile from "./PreviewTile.svelte";
|
||||
|
||||
@@ -16,6 +17,9 @@
|
||||
textVars?: Record<string, string | number>;
|
||||
toolId?: string;
|
||||
running?: boolean;
|
||||
mask?: PixelImage | null;
|
||||
maskOn?: boolean;
|
||||
ontogglemask?: () => void;
|
||||
oncopy?: () => void;
|
||||
ondownloadtxt?: () => void;
|
||||
}
|
||||
@@ -28,11 +32,15 @@
|
||||
textVars = undefined,
|
||||
toolId = "",
|
||||
running = false,
|
||||
mask = null,
|
||||
maskOn = false,
|
||||
ontogglemask,
|
||||
oncopy,
|
||||
ondownloadtxt,
|
||||
}: 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(
|
||||
resultKind === "image" && result
|
||||
? `${result.width} × ${result.height}`
|
||||
@@ -50,7 +58,7 @@
|
||||
let parts = $derived(
|
||||
resultKind === "files" && fileResult ? fileResult.files.length : undefined,
|
||||
);
|
||||
// Размеры одинаковы у всех частей (канвас дополняется) — показываем один раз.
|
||||
// All parts share the same size (canvas is padded); show it once.
|
||||
let partsDims = $derived(
|
||||
resultKind === "files" && fileResult && fileResult.files.length > 0
|
||||
? `${fileResult.files[0].image.width} × ${fileResult.files[0].image.height}`
|
||||
@@ -68,6 +76,15 @@
|
||||
note={resultNote ? t(resultNote.key) : undefined}
|
||||
noteTone={resultNote?.tone ?? "warning"}
|
||||
>
|
||||
{#snippet actions()}
|
||||
{#if ontogglemask}
|
||||
<ChipButton
|
||||
label={t("resultCard.maskToggle")}
|
||||
active={maskOn}
|
||||
onclick={ontogglemask}
|
||||
/>
|
||||
{/if}
|
||||
{/snippet}
|
||||
<div
|
||||
class="canvas"
|
||||
class:checker={resultKind === "image"}
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
|
||||
let dragging = $state(false);
|
||||
let input = $state<HTMLInputElement | null>(null);
|
||||
// Подсказка замены: показываем на ховер и гасим через 10с до нового захода.
|
||||
// Replace hint: show on hover, fade after 10s until the next hover.
|
||||
let hintVisible = $state(false);
|
||||
let hintTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
<script lang="ts">
|
||||
import { goto } from "$app/navigation";
|
||||
import { resolve } from "$app/paths";
|
||||
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 { isFav, toggleFav } from "$lib/favorites.svelte";
|
||||
import { Star } from "@lucide/svelte";
|
||||
@@ -10,6 +15,7 @@
|
||||
decodeFile,
|
||||
downloadBlob,
|
||||
encode,
|
||||
fitWithinBytes,
|
||||
outputFormatByMime,
|
||||
toDataUrl,
|
||||
type OutputMime,
|
||||
@@ -19,22 +25,29 @@
|
||||
import {
|
||||
pageDescription,
|
||||
pageTitle,
|
||||
chainStepTitle,
|
||||
verdictText,
|
||||
} from "$lib/i18n/schema-tool-strings";
|
||||
import { t } from "$lib/i18n/t";
|
||||
import {
|
||||
autoChainName,
|
||||
createNamedChain,
|
||||
getChain,
|
||||
stashHandoff,
|
||||
takeHandoff,
|
||||
updateChainSteps,
|
||||
} from "$lib/chains.svelte";
|
||||
import {
|
||||
createChain,
|
||||
createStep,
|
||||
insertStep,
|
||||
loadChain,
|
||||
isStructuralDefault,
|
||||
moveStep,
|
||||
removeStep,
|
||||
saveChain,
|
||||
type ChainStep,
|
||||
} from "$lib/pipeline.svelte";
|
||||
import {
|
||||
getTool,
|
||||
PAGES,
|
||||
type FileResult,
|
||||
type Page,
|
||||
type Tool,
|
||||
@@ -63,11 +76,13 @@
|
||||
interface Props {
|
||||
page: Page;
|
||||
tool: Tool;
|
||||
/** Named-chain mode: the chain id and display name; page is virtual. */
|
||||
chainId?: string;
|
||||
}
|
||||
type DisplayError =
|
||||
| { kind: "i18n"; key: string; vars?: ErrorVars }
|
||||
| { kind: "plain"; text: string };
|
||||
let { page, tool }: Props = $props();
|
||||
let { page, tool, chainId = undefined }: Props = $props();
|
||||
|
||||
const schema = $derived(
|
||||
tool.schema as ToolSchema<Record<string, unknown>> | undefined,
|
||||
@@ -76,10 +91,10 @@
|
||||
const inputMode = $derived(tool.input);
|
||||
|
||||
let steps = $state<ChainStep[]>([]);
|
||||
// Поля, изменённые пользователем (по шагам): при загрузке нового файла им не
|
||||
// даём перезаписаться source-дефолтами. Reset шага снимает пометку.
|
||||
// Fields edited by the user (per step): on new file load they must not be
|
||||
// overwritten by source defaults. Step reset clears the mark.
|
||||
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"> = {};
|
||||
let stepDims = $state<(Dimension | undefined)[]>([]);
|
||||
let stepResults = $state<(PixelImage | null)[]>([]);
|
||||
@@ -96,8 +111,14 @@
|
||||
let displayError = $state<DisplayError | null>(null);
|
||||
let initedFor = $state("");
|
||||
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>>({});
|
||||
// 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);
|
||||
|
||||
const lastTool = $derived(
|
||||
@@ -105,7 +126,7 @@
|
||||
tool,
|
||||
);
|
||||
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 alignedMode = $derived(
|
||||
aligned && resultKind === "image" && steps.length > 1,
|
||||
@@ -117,8 +138,8 @@
|
||||
!outputFormatByMime(format).supportsAlpha &&
|
||||
hasTransparency(result),
|
||||
);
|
||||
// Если в схеме последнего шага есть quality-параметр (convert-инструменты),
|
||||
// он — единый источник качества; dropdown редактирует его же.
|
||||
// If the last step's schema has a quality param (convert tools), it is the
|
||||
// single source of quality; the dropdown edits the same value.
|
||||
const qualityParamId = $derived(lastTool.output?.qualityParamId);
|
||||
const lastParams = $derived(steps.at(-1)?.params);
|
||||
const resultNote = $derived(
|
||||
@@ -149,6 +170,10 @@
|
||||
: 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);
|
||||
|
||||
function touchedFor(key: string): SvelteSet<string> {
|
||||
@@ -165,21 +190,36 @@
|
||||
}
|
||||
|
||||
function stepTitle(toolId: string): string {
|
||||
const owner = PAGES.find((p) => p.steps[0].id === toolId);
|
||||
return owner ? pageTitle(owner) : toolId;
|
||||
return chainStepTitle(toolId);
|
||||
}
|
||||
|
||||
function init() {
|
||||
if (!schema || page.slug === initedFor) return;
|
||||
initedFor = page.slug;
|
||||
steps = loadChain(page.slug) ?? createChain(page);
|
||||
if (!schema) return;
|
||||
const host = chainId ? `chain:${chainId}` : page.slug;
|
||||
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";
|
||||
limitKb = undefined;
|
||||
source = null;
|
||||
textSource = "";
|
||||
}
|
||||
result = null;
|
||||
fileResult = null;
|
||||
textResult = null;
|
||||
verdictVars = undefined;
|
||||
displayError = null;
|
||||
maskOnKeys.clear();
|
||||
maskResults = {};
|
||||
}
|
||||
|
||||
function setStepValue(
|
||||
@@ -196,8 +236,8 @@
|
||||
const next: Record<string, unknown> = { ...step.params, [id]: value };
|
||||
const stepSchema = toolSchemaOf(step);
|
||||
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];
|
||||
if (dims && stepSchema && spec) {
|
||||
const aspect = dims.width / dims.height;
|
||||
@@ -213,7 +253,7 @@
|
||||
aspect,
|
||||
);
|
||||
} 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)) {
|
||||
const fs = f.spec;
|
||||
if (fs.kind === "dimension" && fs.lockAspectWith === id) {
|
||||
@@ -261,8 +301,8 @@
|
||||
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) {
|
||||
const step = steps[index];
|
||||
const next = createStep(toolId);
|
||||
@@ -397,7 +437,9 @@
|
||||
const out = outputFormatByMime(format);
|
||||
const quality =
|
||||
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}`);
|
||||
} catch (e) {
|
||||
displayError = toDisplayError(e);
|
||||
@@ -433,11 +475,28 @@
|
||||
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(() => {
|
||||
if (!initedFor) return;
|
||||
if (!initedFor || !chainId) return;
|
||||
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(() => {
|
||||
@@ -469,6 +528,41 @@
|
||||
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 {
|
||||
if (e instanceof ToolError) {
|
||||
return { kind: "i18n", key: e.key, vars: e.vars };
|
||||
@@ -476,6 +570,18 @@
|
||||
if (e instanceof Error) return { kind: "plain", text: e.message };
|
||||
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>
|
||||
|
||||
{#if schema}
|
||||
@@ -484,6 +590,7 @@
|
||||
<div class="title-block">
|
||||
<h1>
|
||||
{pageTitle(page)}
|
||||
{#if !chainId}
|
||||
<button
|
||||
type="button"
|
||||
class="tool-fav"
|
||||
@@ -492,11 +599,25 @@
|
||||
aria-pressed={isFav(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>
|
||||
{/if}
|
||||
</h1>
|
||||
{#if pageDescription(page)}
|
||||
<p class="lede">{pageDescription(page)}</p>
|
||||
{/if}
|
||||
</div>
|
||||
{#if !chainId}
|
||||
<Button
|
||||
label={t("savedChains.saveAs")}
|
||||
variant={ButtonVariantDefine.OUTLINE}
|
||||
size="s"
|
||||
onclick={saveAsChain}
|
||||
/>
|
||||
{/if}
|
||||
</header>
|
||||
|
||||
{#snippet stepCard(step: ChainStep, i: number)}
|
||||
@@ -539,6 +660,16 @@
|
||||
</StepCard>
|
||||
{/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}
|
||||
<div class="workspace">
|
||||
<section class="settings">
|
||||
@@ -569,12 +700,23 @@
|
||||
error={errorText}
|
||||
{format}
|
||||
quality={currentQuality}
|
||||
{limitKb}
|
||||
{alphaLoss}
|
||||
{stepResults}
|
||||
{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)}
|
||||
onformat={(v) => (format = v)}
|
||||
onquality={setFormatQuality}
|
||||
onlimit={(v) => (limitKb = v)}
|
||||
onupload={handleFile}
|
||||
ontextsource={(textValue) => {
|
||||
textSource = textValue;
|
||||
@@ -603,11 +745,13 @@
|
||||
{running}
|
||||
{format}
|
||||
quality={currentQuality}
|
||||
{limitKb}
|
||||
{alphaLoss}
|
||||
onupload={handleFile}
|
||||
ondownload={download}
|
||||
onformat={(v) => (format = v)}
|
||||
onquality={setFormatQuality}
|
||||
onlimit={(v) => (limitKb = v)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -667,6 +811,11 @@
|
||||
textVars={verdictVars}
|
||||
toolId={lastTool.id}
|
||||
{running}
|
||||
mask={stepMasks[i] ?? null}
|
||||
maskOn={stepMaskOn[i] ?? false}
|
||||
ontogglemask={stepMaskable[i]
|
||||
? () => toggleStepMask(steps[i].key)
|
||||
: undefined}
|
||||
oncopy={copyText}
|
||||
ondownloadtxt={downloadText}
|
||||
/>
|
||||
@@ -676,8 +825,16 @@
|
||||
viewMode="image"
|
||||
dims={out ? `${out.width} × ${out.height}` : undefined}
|
||||
>
|
||||
{#snippet actions()}
|
||||
{@render maskChip(i)}
|
||||
{/snippet}
|
||||
{#if out}
|
||||
<img src={toDataUrl(out)} alt="" />
|
||||
<img
|
||||
src={toDataUrl(
|
||||
stepMaskOn[i] && stepMasks[i] ? stepMasks[i]! : out,
|
||||
)}
|
||||
alt=""
|
||||
/>
|
||||
{:else}
|
||||
<span class="empty">{t("resultCard.noResult")}</span>
|
||||
{/if}
|
||||
|
||||
@@ -18,12 +18,12 @@ function input(over: Partial<SchemaPreviewModelInput> = {}) {
|
||||
}
|
||||
|
||||
describe("hasPreviewResult", () => {
|
||||
it("image: результат только при наличии картинки", () => {
|
||||
it("image: result only when image present", () => {
|
||||
expect(hasPreviewResult(input({ result: image }))).toBe(true);
|
||||
expect(hasPreviewResult(input())).toBe(false);
|
||||
});
|
||||
|
||||
it("files: результат только при непустом наборе", () => {
|
||||
it("files: result only for non-empty set", () => {
|
||||
const fileResult = { files: [{ name: "part.png", image }] };
|
||||
expect(hasPreviewResult(input({ resultKind: "files", fileResult }))).toBe(
|
||||
true,
|
||||
@@ -36,7 +36,7 @@ describe("hasPreviewResult", () => {
|
||||
});
|
||||
|
||||
it.each(["text", "verdict"] as const)(
|
||||
"%s: результат по наличию текста",
|
||||
"%s: result depends on text presence",
|
||||
(resultKind) => {
|
||||
expect(hasPreviewResult(input({ resultKind, textResult: "done" }))).toBe(
|
||||
true,
|
||||
|
||||
@@ -9,7 +9,7 @@ export type SchemaPreviewModelInput = {
|
||||
textResult: string | null;
|
||||
};
|
||||
|
||||
/** Можно ли скачать результат: он есть и он не пустой. */
|
||||
/** Whether the result is downloadable: it exists and is non-empty. */
|
||||
export function hasPreviewResult(input: SchemaPreviewModelInput): boolean {
|
||||
const { resultKind, result, fileResult, textResult } = input;
|
||||
if (resultKind === "image") return Boolean(result);
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
import { makeImage } from "./test-helpers";
|
||||
|
||||
describe("sampleBilinear", () => {
|
||||
it("целые координаты возвращают точный пиксель", () => {
|
||||
it("integer coordinates return exact pixel", () => {
|
||||
const img = makeImage(2, 1, [
|
||||
[255, 0, 0, 255],
|
||||
[0, 0, 255, 255],
|
||||
@@ -18,7 +18,7 @@ describe("sampleBilinear", () => {
|
||||
expect(sampleBilinear(img, 1, 0)).toEqual([0, 0, 255, 255]);
|
||||
});
|
||||
|
||||
it("дробная координата интерполирует", () => {
|
||||
it("fractional coordinate interpolates", () => {
|
||||
const img = makeImage(2, 1, [
|
||||
[0, 0, 0, 255],
|
||||
[200, 200, 200, 255],
|
||||
@@ -27,14 +27,14 @@ describe("sampleBilinear", () => {
|
||||
expect(r).toBeCloseTo(100, 0);
|
||||
});
|
||||
|
||||
it("координаты за краем клампятся", () => {
|
||||
it("out-of-bounds coordinates are clamped", () => {
|
||||
const img = makeImage(1, 1, [[7, 7, 7, 255]]);
|
||||
expect(sampleBilinear(img, -10, -10)).toEqual([7, 7, 7, 255]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("rotateFreeImage", () => {
|
||||
it("поворот на 180° даёт размеры не меньше исходных и непустой результат", () => {
|
||||
it("180 deg rotation keeps size and fills result", () => {
|
||||
const img = makeImage(4, 3, [
|
||||
[255, 0, 0, 255],
|
||||
[0, 255, 0, 255],
|
||||
@@ -60,7 +60,7 @@ describe("rotateFreeImage", () => {
|
||||
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 out = rotateFreeImage(img, 360);
|
||||
expect(out.width).toBeGreaterThanOrEqual(5);
|
||||
@@ -79,7 +79,7 @@ describe("rotateFreeImage", () => {
|
||||
});
|
||||
|
||||
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 out = skewImage(img, 45, 0);
|
||||
expect(out.width).toBe(3);
|
||||
@@ -96,7 +96,7 @@ describe("skewImage", () => {
|
||||
expect(opaque).toBe(4);
|
||||
});
|
||||
|
||||
it("углы 0° — тождественное преобразование", () => {
|
||||
it("zero angles are identity transform", () => {
|
||||
const img = makeImage(2, 2, [
|
||||
[255, 0, 0, 255],
|
||||
[0, 255, 0, 255],
|
||||
@@ -109,8 +109,8 @@ describe("skewImage", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("transformImage — заливка фона", () => {
|
||||
it("сдвиг с белым фоном заполняет освободившийся край", () => {
|
||||
describe("transformImage - background fill", () => {
|
||||
it("shift with white background fills freed edge", () => {
|
||||
const img = makeImage(2, 2, [
|
||||
[255, 0, 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 out = transformImage(img, [1, 0, 0, 1, -1, 0], 2, 2);
|
||||
expect(out.data[3]).toBe(0);
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
import { makeImage } from "./test-helpers";
|
||||
|
||||
describe("hardenAlpha", () => {
|
||||
it("бинаризует альфу по порогу, RGB не трогает", () => {
|
||||
it("binarizes alpha by threshold, keeps RGB", () => {
|
||||
const out = hardenAlpha(
|
||||
makeImage(2, 1, [
|
||||
[10, 20, 30, 100],
|
||||
@@ -26,7 +26,7 @@ describe("hardenAlpha", () => {
|
||||
});
|
||||
|
||||
describe("setAlphaChannel", () => {
|
||||
it("задаёт константную альфу", () => {
|
||||
it("sets constant alpha", () => {
|
||||
const out = setAlphaChannel(
|
||||
makeImage(2, 1, [
|
||||
[1, 2, 3, 255],
|
||||
@@ -41,7 +41,7 @@ describe("setAlphaChannel", () => {
|
||||
});
|
||||
|
||||
describe("extractAlphaMask", () => {
|
||||
it("переводит альфу в чёрно-белую непрозрачную маску", () => {
|
||||
it("converts alpha to opaque black-white mask", () => {
|
||||
const out = extractAlphaMask(
|
||||
makeImage(2, 1, [
|
||||
[10, 20, 30, 255],
|
||||
@@ -53,7 +53,7 @@ describe("extractAlphaMask", () => {
|
||||
});
|
||||
|
||||
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 out = roundCorners(img, 40);
|
||||
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);
|
||||
});
|
||||
|
||||
it("нулевой радиус ничего не меняет", () => {
|
||||
it("zero radius changes nothing", () => {
|
||||
const img = makeImage(2, 2, new Array(4).fill([1, 2, 3, 255]));
|
||||
expect([...roundCorners(img, 0).data]).toEqual([...img.data]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("invertAlpha", () => {
|
||||
it("обращает альфу, RGB не трогает", () => {
|
||||
it("inverts alpha, keeps RGB", () => {
|
||||
const out = invertAlpha(makeImage(1, 1, [[10, 20, 30, 128]]));
|
||||
expect([...out.data]).toEqual([10, 20, 30, 127]);
|
||||
});
|
||||
@@ -81,7 +81,7 @@ describe("removeColorToAlpha", () => {
|
||||
[255, 0, 0, 255],
|
||||
]);
|
||||
|
||||
it("обнуляет альфу только у точного совпадения при tolerance=0", () => {
|
||||
it("zeroes alpha only on exact match at tolerance=0", () => {
|
||||
const out = removeColorToAlpha(fixture(), "#ff0000", 0);
|
||||
expect(out.data[3]).toBe(255);
|
||||
expect(out.data[7]).toBe(0);
|
||||
@@ -89,7 +89,7 @@ describe("removeColorToAlpha", () => {
|
||||
expect(out.data[5]).toBe(0);
|
||||
});
|
||||
|
||||
it("поддерживает короткую форму и регистр hex", () => {
|
||||
it("supports short form and hex case", () => {
|
||||
expect([...removeColorToAlpha(fixture(), "#f00", 0).data.slice(4)]).toEqual(
|
||||
[255, 0, 0, 0],
|
||||
);
|
||||
@@ -98,13 +98,13 @@ describe("removeColorToAlpha", () => {
|
||||
]).toEqual([255, 0, 0, 0]);
|
||||
});
|
||||
|
||||
it("tolerance 100% удаляет весь диапазон расстояний", () => {
|
||||
it("tolerance 100% removes full distance range", () => {
|
||||
const out = removeColorToAlpha(fixture(), "#000000", 100);
|
||||
expect(out.data[3]).toBe(0);
|
||||
expect(out.data[7]).toBe(0);
|
||||
});
|
||||
|
||||
it("промежуточный tolerance различает близкие и далёкие цвета", () => {
|
||||
it("intermediate tolerance distinguishes near and far colors", () => {
|
||||
const img = makeImage(2, 1, [
|
||||
[0, 0, 0, 255],
|
||||
[128, 128, 128, 255],
|
||||
@@ -117,7 +117,7 @@ describe("removeColorToAlpha", () => {
|
||||
expect(removed.data[7]).toBe(0);
|
||||
});
|
||||
|
||||
it("не мутирует вход", () => {
|
||||
it("does not mutate input", () => {
|
||||
const img = fixture();
|
||||
removeColorToAlpha(img, "#ffffff", 100);
|
||||
expect([...img.data]).toEqual([255, 255, 255, 255, 255, 0, 0, 255]);
|
||||
@@ -125,7 +125,7 @@ describe("removeColorToAlpha", () => {
|
||||
});
|
||||
|
||||
describe("colorMask", () => {
|
||||
it("удаляемые пиксели белые, остальные чёрные, маска непрозрачная", () => {
|
||||
it("removed pixels white, rest black, mask opaque", () => {
|
||||
const out = colorMask(
|
||||
makeImage(2, 1, [
|
||||
[255, 0, 0, 255],
|
||||
@@ -137,7 +137,7 @@ describe("colorMask", () => {
|
||||
expect([...out.data]).toEqual([255, 255, 255, 255, 0, 0, 0, 255]);
|
||||
});
|
||||
|
||||
it("порог совпадает с removeColorToAlpha", () => {
|
||||
it("threshold matches removeColorToAlpha", () => {
|
||||
const img = makeImage(2, 1, [
|
||||
[0, 0, 0, 255],
|
||||
[128, 128, 128, 255],
|
||||
@@ -150,7 +150,7 @@ describe("colorMask", () => {
|
||||
});
|
||||
|
||||
describe("flattenOntoColor", () => {
|
||||
it("непрозрачный пиксель не меняется, альфа становится 255", () => {
|
||||
it("opaque pixel unchanged, alpha becomes 255", () => {
|
||||
const out = flattenOntoColor(
|
||||
makeImage(1, 1, [[10, 20, 30, 255]]),
|
||||
"#ffffff",
|
||||
@@ -158,12 +158,12 @@ describe("flattenOntoColor", () => {
|
||||
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");
|
||||
expect([...out.data]).toEqual([255, 128, 64, 255]);
|
||||
});
|
||||
|
||||
it("полупрозрачный пиксель смешивается с подложкой", () => {
|
||||
it("semi-transparent pixel blends with backdrop", () => {
|
||||
const out = flattenOntoColor(
|
||||
makeImage(1, 1, [[10, 20, 30, 128]]),
|
||||
"#ffffff",
|
||||
@@ -173,13 +173,13 @@ describe("flattenOntoColor", () => {
|
||||
});
|
||||
|
||||
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("#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/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
import { makeImage } from "./test-helpers";
|
||||
|
||||
describe("imageInfo", () => {
|
||||
it("находит полупрозрачные пиксели и считает уникальные RGBA-цвета", () => {
|
||||
it("finds semi-transparent pixels and counts unique RGBA colors", () => {
|
||||
const info = imageInfo(
|
||||
makeImage(2, 2, [
|
||||
[0, 0, 0, 255],
|
||||
@@ -25,7 +25,7 @@ describe("imageInfo", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("полностью непрозрачное изображение — hasAlpha false", () => {
|
||||
it("fully opaque image - hasAlpha false", () => {
|
||||
const info = imageInfo(
|
||||
makeImage(2, 1, [
|
||||
[10, 20, 30, 255],
|
||||
@@ -36,7 +36,7 @@ describe("imageInfo", () => {
|
||||
expect(info.colorCount).toBe(2);
|
||||
});
|
||||
|
||||
it("разная альфа означает разные цвета", () => {
|
||||
it("different alpha means different colors", () => {
|
||||
const info = imageInfo(
|
||||
makeImage(2, 1, [
|
||||
[255, 0, 0, 255],
|
||||
@@ -47,7 +47,7 @@ describe("imageInfo", () => {
|
||||
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])));
|
||||
expect(info.width).toBe(5);
|
||||
expect(info.height).toBe(3);
|
||||
@@ -55,25 +55,25 @@ describe("imageInfo", () => {
|
||||
});
|
||||
|
||||
describe("isGrayscale", () => {
|
||||
it("серые пиксели — монохром", () => {
|
||||
it("gray pixels are monochrome", () => {
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
describe("hasTransparency", () => {
|
||||
it("альфа ниже 255 — прозрачность есть", () => {
|
||||
it("alpha below 255 means transparency", () => {
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
describe("special decoded pixel data", () => {
|
||||
it("палитра после browser-декодирования сохраняет количество цветов", () => {
|
||||
it("palette keeps color count after browser decode", () => {
|
||||
const image = makeImage(2, 2, [
|
||||
[255, 0, 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(
|
||||
4,
|
||||
4,
|
||||
@@ -107,7 +107,7 @@ describe("special decoded pixel data", () => {
|
||||
});
|
||||
|
||||
describe("orientationOf", () => {
|
||||
it("определяет ориентацию", () => {
|
||||
it("detects orientation", () => {
|
||||
expect(
|
||||
orientationOf(makeImage(2, 3, new Array(6).fill([1, 1, 1, 1]))),
|
||||
).toBe("portrait");
|
||||
|
||||
@@ -10,7 +10,7 @@ const GREEN = [0, 255, 0, 255];
|
||||
const RED = [255, 0, 0, 255];
|
||||
|
||||
describe("backgroundRemovalMask", () => {
|
||||
it("глобальный режим удаляет все совпадающие пиксели", () => {
|
||||
it("global mode removes all matching pixels", () => {
|
||||
const mask = backgroundRemovalMask(makeImage(2, 1, [GREEN, RED]), {
|
||||
color: "#00ff00",
|
||||
tolerancePercent: 0,
|
||||
@@ -20,10 +20,10 @@ describe("backgroundRemovalMask", () => {
|
||||
expect([...mask]).toEqual([1, 0]);
|
||||
});
|
||||
|
||||
describe("режим внешних областей", () => {
|
||||
describe("outer regions mode", () => {
|
||||
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), {
|
||||
color: "#00ff00",
|
||||
tolerancePercent: 0,
|
||||
@@ -33,7 +33,7 @@ describe("backgroundRemovalMask", () => {
|
||||
expect(mask[4]).toBe(0);
|
||||
});
|
||||
|
||||
it("глобальный режим удаляет и изолированный остров", () => {
|
||||
it("global mode removes isolated island too", () => {
|
||||
const mask = backgroundRemovalMask(makeImage(3, 3, ringRedCenterGreen), {
|
||||
color: "#00ff00",
|
||||
tolerancePercent: 0,
|
||||
@@ -45,7 +45,7 @@ describe("backgroundRemovalMask", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("допуск расширяет захват по цветовому расстоянию", () => {
|
||||
it("tolerance widens capture by color distance", () => {
|
||||
const img = makeImage(2, 1, [
|
||||
[10, 10, 10, 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 opts = { color: "#00ff00", tolerancePercent: 0, outerOnly: false };
|
||||
|
||||
const alphaAtCenter = (img: { data: Uint8ClampedArray }) => img.data[19];
|
||||
|
||||
it("без сглаживания центр удалён", () => {
|
||||
it("without smoothing center is removed", () => {
|
||||
const img = removeBackground(makeImage(3, 3, ringRedCenterGreen), {
|
||||
...opts,
|
||||
smoothPasses: 0,
|
||||
@@ -81,7 +81,7 @@ describe("smoothMask-поведение через backgroundRemovalMask", () =>
|
||||
expect(alphaAtCenter(img)).toBe(0);
|
||||
});
|
||||
|
||||
it("два прохода мажоритарного фильтра возвращают изолированный пиксель", () => {
|
||||
it("two majority filter passes restore isolated pixel", () => {
|
||||
const img = removeBackground(makeImage(3, 3, ringRedCenterGreen), {
|
||||
...opts,
|
||||
smoothPasses: 2,
|
||||
@@ -91,7 +91,7 @@ describe("smoothMask-поведение через backgroundRemovalMask", () =>
|
||||
});
|
||||
|
||||
describe("removeBackground", () => {
|
||||
it("обнуляет альфу удалённых, сохраняет RGB остальных", () => {
|
||||
it("zeroes alpha of removed, keeps RGB of rest", () => {
|
||||
const out = removeBackground(makeImage(2, 1, [GREEN, [5, 6, 7, 200]]), {
|
||||
color: "#00ff00",
|
||||
tolerancePercent: 0,
|
||||
@@ -104,7 +104,7 @@ describe("removeBackground", () => {
|
||||
});
|
||||
|
||||
describe("backgroundMaskPreview", () => {
|
||||
it("белое там, где удаление, чёрное — где остаёмся, всё непрозрачно", () => {
|
||||
it("white where removed, black where kept, all opaque", () => {
|
||||
const preview = backgroundMaskPreview(
|
||||
makeImage(2, 1, [GREEN, [9, 9, 9, 60]]),
|
||||
{
|
||||
|
||||
@@ -3,7 +3,7 @@ import { encodeBmpBytes } from "./bmp";
|
||||
import { makeImage } from "./test-helpers";
|
||||
|
||||
describe("encodeBmpBytes", () => {
|
||||
it("пишет заголовки BM и размеры для 2x1 без паддинга-неопределённости", () => {
|
||||
it("writes BM headers and sizes for 2x1 without padding ambiguity", () => {
|
||||
const bytes = encodeBmpBytes(
|
||||
makeImage(2, 1, [
|
||||
[255, 0, 0, 255],
|
||||
@@ -18,7 +18,7 @@ describe("encodeBmpBytes", () => {
|
||||
expect(view.getUint16(28, true)).toBe(24);
|
||||
});
|
||||
|
||||
it("хранит пиксели в BGR снизу-вверх с паддингом строки", () => {
|
||||
it("stores pixels as BGR bottom-up with row padding", () => {
|
||||
const bytes = encodeBmpBytes(
|
||||
makeImage(2, 1, [
|
||||
[255, 0, 0, 255],
|
||||
@@ -30,7 +30,7 @@ describe("encodeBmpBytes", () => {
|
||||
expect(bytes[61]).toBe(0);
|
||||
});
|
||||
|
||||
it("нижняя строка изображения идёт первой в файле", () => {
|
||||
it("bottom image row comes first in file", () => {
|
||||
const bytes = encodeBmpBytes(
|
||||
makeImage(4, 2, [
|
||||
[10, 10, 10, 255],
|
||||
|
||||
@@ -3,39 +3,39 @@ import { hexToRgb } from "./palette";
|
||||
import { renderSpace, SPACES } from "./channels";
|
||||
import { makeImage } from "./test-helpers";
|
||||
|
||||
describe("преобразования пространств", () => {
|
||||
it("hsl красного: h=0, s=1, l=0.5", () => {
|
||||
describe("space conversions", () => {
|
||||
it("hsl of red: h=0, s=1, l=0.5", () => {
|
||||
const [h, s, l] = SPACES.hsl.convert(hexToRgb("#ff0000"));
|
||||
expect(h).toBeCloseTo(0);
|
||||
expect(s).toBeCloseTo(1);
|
||||
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: 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 });
|
||||
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 b = SPACES.cmyk.convert({ r: 0, g: 0, b: 0 });
|
||||
expect(w.every((v) => Math.abs(v) < 1e-9)).toBe(true);
|
||||
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 });
|
||||
expect(y).toBeCloseTo(1);
|
||||
expect(cb).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 });
|
||||
expect(l).toBeCloseTo(1, 2);
|
||||
expect(a).toBeCloseTo(0.5, 2);
|
||||
@@ -46,19 +46,19 @@ describe("преобразования пространств", () => {
|
||||
describe("renderSpace", () => {
|
||||
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");
|
||||
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");
|
||||
expect(out.data[0]).toBe(0);
|
||||
expect(out.data[1]).toBe(255);
|
||||
expect(out.data[2]).toBeGreaterThanOrEqual(127);
|
||||
});
|
||||
|
||||
it("неизвестное пространство или компонент дают прозрачную заглушку", () => {
|
||||
it("unknown space or component yields transparent placeholder", () => {
|
||||
const junk = makeImage(1, 1, [[10, 20, 30, 255]]);
|
||||
expect(
|
||||
renderSpace(junk, "cmyk" as never, "zz" as never, "gray").data[3],
|
||||
|
||||
@@ -3,7 +3,7 @@ import { createPixelImage } from "./types";
|
||||
import { rgbToHsl } from "./palette";
|
||||
import type { Rgb } from "./palette";
|
||||
|
||||
/** Все компоненты нормализованы в 0..1 в порядке объявления. */
|
||||
/** All components normalized to 0..1 in declaration order. */
|
||||
export type SpaceComponents = number[];
|
||||
|
||||
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 fy = f(y);
|
||||
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 [
|
||||
(116 * fy - 16) / 100,
|
||||
(500 * (fx - fy)) / 250 + 0.5,
|
||||
@@ -106,9 +106,9 @@ export const SPACES: Record<SpaceId, SpaceDef> = {
|
||||
export type ChannelDisplay = "gray" | "color";
|
||||
|
||||
/**
|
||||
* Визуализация выбранного пространства: каждый компонент пространства
|
||||
* попадает в свой канал результата (mode 'color') или выбранный компонент
|
||||
* рисуется градациями серого (mode 'gray').
|
||||
* Visualizes the selected space: each component goes to its own result
|
||||
* channel (mode 'color'), or the selected component is drawn in grayscale
|
||||
* (mode 'gray').
|
||||
*/
|
||||
export function renderSpace(
|
||||
img: PixelImage,
|
||||
|
||||
@@ -20,23 +20,23 @@ import {
|
||||
import { makeImage } from "./test-helpers";
|
||||
|
||||
describe("rgbToHex", () => {
|
||||
it("форматирует базовые цвета", () => {
|
||||
it("formats base colors", () => {
|
||||
expect(rgbToHex(255, 0, 0)).toBe("#ff0000");
|
||||
expect(rgbToHex(1, 2, 3)).toBe("#010203");
|
||||
});
|
||||
|
||||
it("округляет дробные значения и клампит диапазон", () => {
|
||||
it("rounds fractional values and clamps range", () => {
|
||||
expect(rgbToHex(127.6, -5, 300)).toBe("#8000ff");
|
||||
});
|
||||
});
|
||||
|
||||
describe("setOpacity", () => {
|
||||
it("умножает альфу на процент, RGB не трогает", () => {
|
||||
it("multiplies alpha by percent, keeps RGB", () => {
|
||||
const out = setOpacity(makeImage(1, 1, [[10, 20, 30, 128]]), 50);
|
||||
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(
|
||||
200,
|
||||
);
|
||||
@@ -45,7 +45,7 @@ describe("setOpacity", () => {
|
||||
});
|
||||
|
||||
describe("sepia", () => {
|
||||
it("применяет классическую матрицу с клампом", () => {
|
||||
it("applies classic matrix with clamping", () => {
|
||||
const out = sepia(
|
||||
makeImage(2, 1, [
|
||||
[255, 0, 0, 255],
|
||||
@@ -60,26 +60,26 @@ describe("sepia", () => {
|
||||
});
|
||||
|
||||
describe("changeHue", () => {
|
||||
it("чистый красный при +120° становится чистым зелёным", () => {
|
||||
it("pure red at +120 deg becomes pure green", () => {
|
||||
const out = changeHue(makeImage(1, 1, [[255, 0, 0, 255]]), 120);
|
||||
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]]);
|
||||
expect([...changeHue(img, 360).data]).toEqual([...img.data]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("extractChannel", () => {
|
||||
it("выдаёт выбранный канал оттенками серого", () => {
|
||||
it("renders selected channel in grayscale", () => {
|
||||
const out = extractChannel(makeImage(1, 1, [[10, 20, 30, 40]]), "green");
|
||||
expect([...out.data]).toEqual([20, 20, 20, 40]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("swapChannels", () => {
|
||||
it("переставляет каналы парами", () => {
|
||||
it("swaps channels in pairs", () => {
|
||||
expect([
|
||||
...swapChannels(makeImage(1, 1, [[10, 20, 30, 40]]), "r-b").data,
|
||||
]).toEqual([30, 20, 10, 40]);
|
||||
@@ -90,7 +90,7 @@ describe("swapChannels", () => {
|
||||
});
|
||||
|
||||
describe("thresholdBlackWhite", () => {
|
||||
it("серый 128 относительно порога 50% — белый", () => {
|
||||
it("gray 128 vs 50% threshold - white", () => {
|
||||
expect(
|
||||
thresholdBlackWhite(makeImage(1, 1, [[128, 128, 128, 255]]), 50).data[0],
|
||||
).toBe(255);
|
||||
@@ -101,7 +101,7 @@ describe("thresholdBlackWhite", () => {
|
||||
});
|
||||
|
||||
describe("posterize", () => {
|
||||
it("два уровня квантуют в чёрное и белое", () => {
|
||||
it("two levels quantize to black and white", () => {
|
||||
const out = posterize(
|
||||
makeImage(2, 1, [
|
||||
[100, 100, 100, 255],
|
||||
@@ -115,7 +115,7 @@ describe("posterize", () => {
|
||||
});
|
||||
|
||||
describe("twoColors", () => {
|
||||
it("яркие пиксели получают светлый цвет, тёмные — тёмный", () => {
|
||||
it("bright pixels get light color, dark get dark", () => {
|
||||
const out = twoColors(
|
||||
makeImage(2, 1, [
|
||||
[250, 250, 250, 255],
|
||||
@@ -131,7 +131,7 @@ describe("twoColors", () => {
|
||||
});
|
||||
|
||||
describe("grayscale", () => {
|
||||
it("считает luma по весам BT.601 с округлением", () => {
|
||||
it("computes luma with BT.601 weights and rounding", () => {
|
||||
const out = grayscale(
|
||||
makeImage(3, 1, [
|
||||
[255, 0, 0, 255],
|
||||
@@ -146,7 +146,7 @@ describe("grayscale", () => {
|
||||
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 out = grayscale(img);
|
||||
expect([...out.data]).toEqual([18, 18, 18, 200]);
|
||||
@@ -155,7 +155,7 @@ describe("grayscale", () => {
|
||||
});
|
||||
|
||||
describe("invert", () => {
|
||||
it("инвертирует RGB, не трогая альфу", () => {
|
||||
it("inverts RGB, keeps alpha", () => {
|
||||
const out = invert(makeImage(1, 1, [[10, 200, 30, 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 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);
|
||||
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);
|
||||
expect(red([...out.data])).toEqual([255, 255, 255]);
|
||||
});
|
||||
|
||||
it("brightness -100 заливает чёрным", () => {
|
||||
it("brightness -100 fills with black", () => {
|
||||
const out = brightnessContrast(pixel(240), -100, 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);
|
||||
expect(red([...out.data])).toEqual([128, 128, 128]);
|
||||
});
|
||||
|
||||
it("параметры вне диапазона клампятся", () => {
|
||||
it("out-of-range params are clamped", () => {
|
||||
const out = brightnessContrast(pixel(10), 150, 0);
|
||||
expect(red([...out.data])).toEqual([255, 255, 255]);
|
||||
});
|
||||
|
||||
it("альфа не меняется", () => {
|
||||
it("alpha unchanged", () => {
|
||||
const out = brightnessContrast(makeImage(1, 1, [[10, 20, 30, 64]]), 50, 50);
|
||||
expect(out.data[3]).toBe(64);
|
||||
});
|
||||
});
|
||||
|
||||
describe("gammaCorrection", () => {
|
||||
it("гамма 1 — идентичность", () => {
|
||||
it("gamma 1 - identity", () => {
|
||||
const img = makeImage(1, 1, [[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);
|
||||
expect(out.data[0]).toBe(128);
|
||||
});
|
||||
});
|
||||
|
||||
describe("autoContrast", () => {
|
||||
it("растягивает диапазон [10..200] до [0..255]", () => {
|
||||
it("stretches range [10..200] to [0..255]", () => {
|
||||
const out = autoContrast(
|
||||
makeImage(2, 1, [
|
||||
[10, 10, 10, 255],
|
||||
@@ -221,23 +221,23 @@ describe("autoContrast", () => {
|
||||
});
|
||||
|
||||
describe("temperature", () => {
|
||||
it("положительная — теплее (красный ↑, синий ↓)", () => {
|
||||
it("positive - warmer (red up, blue down)", () => {
|
||||
const out = temperature(makeImage(1, 1, [[128, 128, 128, 255]]), 50);
|
||||
expect(out.data[0]).toBeGreaterThan(128);
|
||||
expect(out.data[2]).toBeLessThan(128);
|
||||
});
|
||||
it("нулевая температура не меняет", () => {
|
||||
it("zero temperature changes nothing", () => {
|
||||
const img = makeImage(1, 1, [[128, 128, 128, 255]]);
|
||||
expect([...temperature(img, 0).data]).toEqual([...img.data]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("tint", () => {
|
||||
it("сила 100 на белом даёт чистый цвет", () => {
|
||||
it("strength 100 on white yields pure color", () => {
|
||||
const out = tint(makeImage(1, 1, [[255, 255, 255, 255]]), "#ff0000", 100);
|
||||
expect([...out.data]).toEqual([255, 0, 0, 255]);
|
||||
});
|
||||
it("сила 0 — идентичность", () => {
|
||||
it("strength 0 - identity", () => {
|
||||
const img = makeImage(1, 1, [[100, 150, 200, 255]]);
|
||||
expect([...tint(img, "#ff0000", 0).data]).toEqual([...img.data]);
|
||||
});
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { COMPRESSION_LEVELS, findMaxColorsWithin } from "./compress";
|
||||
import {
|
||||
COMPRESSION_LEVELS,
|
||||
findMaxColorsWithin,
|
||||
findQualityWithin,
|
||||
} from "./compress";
|
||||
|
||||
describe("COMPRESSION_LEVELS", () => {
|
||||
it("пресеты упорядочены по убыванию цветов", () => {
|
||||
it("presets ordered by descending colors", () => {
|
||||
const values = Object.values(COMPRESSION_LEVELS);
|
||||
for (let i = 1; i < values.length; i++)
|
||||
expect(values[i - 1]).toBeGreaterThan(values[i]);
|
||||
@@ -10,26 +14,26 @@ describe("COMPRESSION_LEVELS", () => {
|
||||
});
|
||||
|
||||
describe("findMaxColorsWithin", () => {
|
||||
const sizeOf = (k: number) => 1000 + k * 100; // размер растёт с k
|
||||
const sizeOf = (k: number) => 1000 + k * 100; // size grows with k
|
||||
|
||||
it("подбирает максимум k, укладывающийся в цель", async () => {
|
||||
// target 5200 → подходит k≤42 → ожидаем 42 при maxK≥42
|
||||
it("picks max k fitting the target", async () => {
|
||||
// target 5200 -> k<=42 fits -> expect 42 at maxK>=42
|
||||
const k = await findMaxColorsWithin(5200, 256, async (kk) => sizeOf(kk));
|
||||
expect(k).toBeGreaterThanOrEqual(40);
|
||||
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));
|
||||
expect(k).toBe(2);
|
||||
});
|
||||
|
||||
it("encodeSize null трактуется как провал", async () => {
|
||||
it("encodeSize null treated as failure", async () => {
|
||||
const k = await findMaxColorsWithin(999999, 16, async () => null);
|
||||
expect(k).toBe(2);
|
||||
});
|
||||
|
||||
it("бинарный поиск сходится быстрее полного перебора", async () => {
|
||||
it("binary search converges faster than full scan", async () => {
|
||||
let calls = 0;
|
||||
await findMaxColorsWithin(30000, 256, async (kk) => {
|
||||
calls++;
|
||||
@@ -38,3 +42,38 @@ describe("findMaxColorsWithin", () => {
|
||||
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
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/** Пресеты сжатия: уровень → число цветов квантования. */
|
||||
/** Compression presets: level -> number of quantization colors. */
|
||||
export const COMPRESSION_LEVELS = {
|
||||
light: 192,
|
||||
balanced: 96,
|
||||
@@ -9,9 +9,9 @@ export const COMPRESSION_LEVELS = {
|
||||
export type CompressionLevel = keyof typeof COMPRESSION_LEVELS;
|
||||
|
||||
/**
|
||||
* Бинарный поиск наибольшего k ∈ [2..maxK], при котором закодированный размер
|
||||
* укладывается в targetBytes. Если даже k=2 не влезает — возвращается 2 (best effort).
|
||||
* encodeSize может вернуть null (ошибка кодирования) — трактуется как «не влезло».
|
||||
* Binary search for the largest k in [2..maxK] whose encoded size fits
|
||||
* targetBytes. If even k=2 does not fit, returns 2 (best effort).
|
||||
* encodeSize may return null (encode error), treated as "does not fit".
|
||||
*/
|
||||
export async function findMaxColorsWithin(
|
||||
targetBytes: number,
|
||||
@@ -44,3 +44,39 @@ export async function findMaxColorsWithin(
|
||||
void fitsAtTwo;
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import { makeImage } from "./test-helpers";
|
||||
const SHARPEN_KERNEL = [0, -1, 0, -1, 5, -1, 0, -1, 0];
|
||||
|
||||
describe("convolve", () => {
|
||||
it("крестовое ядро резкости на полоске из трёх пикселей", () => {
|
||||
it("cross sharpen kernel on three-pixel strip", () => {
|
||||
const out = convolve(
|
||||
makeImage(3, 1, [
|
||||
[0, 0, 0, 255],
|
||||
@@ -24,7 +24,7 @@ describe("convolve", () => {
|
||||
[2, 3],
|
||||
[3.5, 3],
|
||||
[0, 3],
|
||||
])("бросает ошибку на некорректном размере ядра %i", (size) => {
|
||||
])("throws on invalid kernel size %i", (size) => {
|
||||
expect(() =>
|
||||
convolve(makeImage(1, 1, [[0, 0, 0, 255]]), [1], size as number),
|
||||
).toThrow();
|
||||
@@ -32,7 +32,7 @@ describe("convolve", () => {
|
||||
});
|
||||
|
||||
describe("sharpen", () => {
|
||||
it("сила 0 возвращает копию", () => {
|
||||
it("strength 0 returns copy", () => {
|
||||
const img = makeImage(2, 2, [
|
||||
[10, 20, 30, 255],
|
||||
[40, 50, 60, 128],
|
||||
@@ -42,7 +42,7 @@ describe("sharpen", () => {
|
||||
expect([...sharpen(img, 0).data]).toEqual([...img.data]);
|
||||
});
|
||||
|
||||
it("сила 100 применяет чистое ядро резкости", () => {
|
||||
it("strength 100 applies pure sharpen kernel", () => {
|
||||
const out = sharpen(
|
||||
makeImage(3, 1, [
|
||||
[0, 0, 0, 255],
|
||||
@@ -57,7 +57,7 @@ describe("sharpen", () => {
|
||||
});
|
||||
|
||||
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 out = gaussianBlur(img, 16);
|
||||
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 pixels: number[][] = [];
|
||||
for (let y = 0; y < size; y++) {
|
||||
@@ -89,7 +89,7 @@ describe("gaussianBlur", () => {
|
||||
expect(out.data[center + 2]).toBe(25);
|
||||
});
|
||||
|
||||
it("симметричный вход даёт симметричный результат", () => {
|
||||
it("symmetric input yields symmetric result", () => {
|
||||
const leftByRow = [
|
||||
[
|
||||
[255, 0, 0, 255],
|
||||
|
||||
@@ -4,16 +4,16 @@ import { formatStamp } from "./datefmt";
|
||||
const d = new Date(2026, 7, 25, 9, 5, 3);
|
||||
|
||||
describe("formatStamp", () => {
|
||||
it("разворачивает все базовые токены", () => {
|
||||
it("expands all base tokens", () => {
|
||||
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, "YYYY год, MM месяц")).toBe("2026 год, 08 месяц");
|
||||
expect(formatStamp(d, "YYYY year, MM month")).toBe("2026 year, 08 month");
|
||||
});
|
||||
|
||||
it("неизвестные последовательности не трогаются", () => {
|
||||
expect(formatStamp(d, "YYYYYY MMМ")).toBe("2026YY 08М");
|
||||
it("unknown sequences are left alone", () => {
|
||||
expect(formatStamp(d, "YYYYYY MMX")).toBe("2026YY 08X");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
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 {
|
||||
return pattern.replace(/YYYY|MM|DD|hh|mm|ss/g, (token) => {
|
||||
|
||||
@@ -56,7 +56,7 @@ export interface TextBlockOptions {
|
||||
angleDeg?: number;
|
||||
}
|
||||
|
||||
/** Одна надпись (с автопереносом и опциональной плашкой) поверх изображения. */
|
||||
/** One caption (auto-wrapped, optional backing plate) over the image. */
|
||||
export function drawTextBlock(
|
||||
img: PixelImage,
|
||||
o: TextBlockOptions,
|
||||
@@ -132,7 +132,7 @@ export interface TextToImageOptions {
|
||||
maxTextWidth?: number;
|
||||
}
|
||||
|
||||
/** Картинка из текста: холст подгоняется под размер надписи с паддингом. */
|
||||
/** Image from text: canvas sized to the caption plus padding. */
|
||||
export function renderTextToImage(o: TextToImageOptions): PixelImage {
|
||||
const measure = ctx2d(8, 8).ctx;
|
||||
measure.font = fontString(o.fontSize, o.font, o.bold);
|
||||
@@ -161,7 +161,7 @@ export function renderTextToImage(o: TextToImageOptions): PixelImage {
|
||||
return toPixelImage(canvas);
|
||||
}
|
||||
|
||||
/** Эмодзи/символ как PNG: рисуется платформенным шрифтом по центру. */
|
||||
/** Emoji/symbol as PNG: drawn centered with the platform emoji font. */
|
||||
export function renderEmoji(symbol: string, size: number): PixelImage {
|
||||
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`;
|
||||
@@ -179,7 +179,7 @@ export interface ImageWatermarkOptions {
|
||||
margin: number;
|
||||
}
|
||||
|
||||
/** Картинка-знак поверх изображения: масштаб от ширины холста, позиция 3×3. */
|
||||
/** Image mark over the image: scale relative to canvas width, 3x3 position. */
|
||||
export function drawImageWatermark(
|
||||
img: PixelImage,
|
||||
o: ImageWatermarkOptions,
|
||||
@@ -221,7 +221,7 @@ export function drawImageWatermark(
|
||||
return toPixelImage(canvas);
|
||||
}
|
||||
|
||||
/** Повторяющаяся диагональная плитка текста на весь холст. */
|
||||
/** Repeating diagonal text tile across the whole canvas. */
|
||||
export function drawTextTile(img: PixelImage, o: TileTextOptions): PixelImage {
|
||||
const { canvas, ctx } = ctx2d(img.width, img.height);
|
||||
ctx.putImageData(
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { vignette } from "./effects";
|
||||
import { vignette, vignetteMask } from "./effects";
|
||||
import { makeImage } from "./test-helpers";
|
||||
|
||||
describe("vignette", () => {
|
||||
it("сила 0 — идентичное преобразование", () => {
|
||||
it("strength 0 - identity transform", () => {
|
||||
const img = makeImage(3, 3, new Array(9).fill([200, 100, 50, 255]));
|
||||
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 out = vignette(img, 80);
|
||||
const centerR = out.data[(3 * 7 + 3) * 4];
|
||||
@@ -17,3 +17,19 @@ describe("vignette", () => {
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -22,3 +22,30 @@ export function vignette(img: PixelImage, strengthPercent: number): PixelImage {
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
export type ErrorVars = Record<string, string | number>;
|
||||
|
||||
/**
|
||||
* Ошибка с стабильным ключом перевода вместо готового текста.
|
||||
* Ядро бросает только её; человекочитаемый текст подставляет слой UI
|
||||
* по секции errors активной локали.
|
||||
* Error with a stable translation key instead of ready-made text.
|
||||
* Core throws only this; the UI layer supplies human-readable text
|
||||
* from the errors section of the active locale.
|
||||
*/
|
||||
export class ToolError extends Error {
|
||||
readonly key: string;
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
import { makeImage } from "./test-helpers";
|
||||
|
||||
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]));
|
||||
for (let y = 1; y <= 2; y++) {
|
||||
for (let x = 1; x <= 2; x++) {
|
||||
@@ -24,11 +24,11 @@ const bordered = () => {
|
||||
};
|
||||
|
||||
describe("contentBounds / trimToContent", () => {
|
||||
it("границы по порогу альфы", () => {
|
||||
it("bounds by alpha threshold", () => {
|
||||
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);
|
||||
expect(out.width).toBe(2);
|
||||
expect(out.height).toBe(2);
|
||||
@@ -36,7 +36,7 @@ describe("contentBounds / trimToContent", () => {
|
||||
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 out = trimToContent(empty, 0);
|
||||
expect(out.width).toBe(1);
|
||||
@@ -52,35 +52,35 @@ describe("changeCanvasSize", () => {
|
||||
[4, 4, 4, 255],
|
||||
]);
|
||||
|
||||
it("увеличение с якорем center — прозрачные поля со всех сторон", () => {
|
||||
it("grow with center anchor - transparent margins all around", () => {
|
||||
const out = changeCanvasSize(img, 4, 4, "center");
|
||||
expect(out.width).toBe(4);
|
||||
expect(out.data[3]).toBe(0);
|
||||
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");
|
||||
expect(out.data[3]).toBe(255);
|
||||
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");
|
||||
expect(out.data[0]).toBe(4);
|
||||
});
|
||||
});
|
||||
|
||||
describe("соотношение сторон", () => {
|
||||
describe("aspect ratio", () => {
|
||||
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);
|
||||
expect(out.width).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);
|
||||
expect(out.width).toBe(400);
|
||||
expect(out.height).toBe(400);
|
||||
@@ -90,21 +90,21 @@ describe("соотношение сторон", () => {
|
||||
});
|
||||
|
||||
describe("forceOrientation / symmetricCopy", () => {
|
||||
it("широкое становится высоким поворотом", () => {
|
||||
it("wide becomes tall via rotation", () => {
|
||||
const wide = makeImage(300, 100, new Array(30000).fill([5, 5, 5, 255]));
|
||||
const out = forceOrientation(wide, "portrait");
|
||||
expect(out.width).toBe(100);
|
||||
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 out = forceOrientation(sq, "portrait");
|
||||
expect(out.width).toBe(50);
|
||||
expect(out.height).toBe(50);
|
||||
});
|
||||
|
||||
it("симметричная копия удваивает ширину и зеркалит правую половину", () => {
|
||||
it("symmetric copy doubles width and mirrors right half", () => {
|
||||
const img = makeImage(2, 1, [
|
||||
[10, 0, 0, 255],
|
||||
[20, 0, 0, 255],
|
||||
@@ -112,18 +112,18 @@ describe("forceOrientation / symmetricCopy", () => {
|
||||
const out = symmetricCopy(img, "vertical", "left");
|
||||
expect(out.width).toBe(4);
|
||||
expect(out.data[0]).toBe(10);
|
||||
expect(out.data[8]).toBe(20); // пиксель 2 = зеркало начала
|
||||
expect(out.data[12]).toBe(10); // пиксель 3 = зеркало конца
|
||||
expect(out.data[8]).toBe(20); // pixel 2 = mirror of start
|
||||
expect(out.data[12]).toBe(10); // pixel 3 = mirror of end
|
||||
});
|
||||
|
||||
it("вертикальная ось удваивает высоту", () => {
|
||||
it("vertical axis doubles height", () => {
|
||||
const img = makeImage(1, 2, [
|
||||
[10, 0, 0, 255],
|
||||
[30, 0, 0, 255],
|
||||
]);
|
||||
const out = symmetricCopy(img, "horizontal", "top");
|
||||
expect(out.height).toBe(4);
|
||||
expect(out.data[(2 * 1 + 0) * 4]).toBe(30); // строка 2 = зеркало строки 1
|
||||
expect(out.data[(3 * 1 + 0) * 4]).toBe(10); // строка 3 = зеркало строки 0
|
||||
expect(out.data[(2 * 1 + 0) * 4]).toBe(30); // row 2 = mirror of row 1
|
||||
expect(out.data[(3 * 1 + 0) * 4]).toBe(10); // row 3 = mirror of row 0
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,13 +2,13 @@ import { describe, expect, it } from "vitest";
|
||||
import { colorSpectrum, drawGrid, randomColorBlocks } from "./gen-tools";
|
||||
|
||||
describe("colorSpectrum", () => {
|
||||
it("горизонтальный: левый край красный (hue 0)", () => {
|
||||
it("horizontal: left edge red (hue 0)", () => {
|
||||
const img = colorSpectrum(100, 10, "horizontal", 100, 50);
|
||||
expect(img.data[0]).toBeGreaterThan(200);
|
||||
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 rowHue120 = Math.round((120 / 360) * 99);
|
||||
const g = img.data[(rowHue120 * 10 + 5) * 4 + 1];
|
||||
@@ -17,7 +17,7 @@ describe("colorSpectrum", () => {
|
||||
});
|
||||
|
||||
describe("randomColorBlocks", () => {
|
||||
it("детерминирован по seed и блоки однотонные", () => {
|
||||
it("deterministic by seed, blocks are solid", () => {
|
||||
const a = randomColorBlocks(64, 64, 16, 7);
|
||||
const b = randomColorBlocks(64, 64, 16, 7);
|
||||
expect([...a.data]).toEqual([...b.data]);
|
||||
@@ -28,21 +28,21 @@ describe("randomColorBlocks", () => {
|
||||
});
|
||||
|
||||
describe("drawGrid", () => {
|
||||
it("линии на пересечениях непрозрачны, фон прозрачен", () => {
|
||||
it("grid lines opaque, background transparent", () => {
|
||||
const out = drawGrid(100, 100, 4, 4, 2, "#000000", 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;
|
||||
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 mid = (5 * 20 + 5) * 4;
|
||||
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 mid = (5 * 20 + 5) * 4;
|
||||
expect(out.data[mid + 3]).toBe(128);
|
||||
|
||||
@@ -2,7 +2,7 @@ import { createPixelImage, type PixelImage } from "./types";
|
||||
import { hslToRgb } from "./palette";
|
||||
import { mulberry32 } from "./pixel-fx";
|
||||
|
||||
/** Радужный спектр: оттенок 0..360 вдоль выбранной оси. */
|
||||
/** Rainbow spectrum: hue 0..360 along the chosen axis. */
|
||||
export function colorSpectrum(
|
||||
width: number,
|
||||
height: number,
|
||||
@@ -32,7 +32,7 @@ export function colorSpectrum(
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Случайные яркие блоки: детерминировано по seed. */
|
||||
/** Random vivid blocks: deterministic per seed. */
|
||||
export function randomColorBlocks(
|
||||
width: number,
|
||||
height: number,
|
||||
@@ -79,7 +79,7 @@ function lineMask(
|
||||
return mask;
|
||||
}
|
||||
|
||||
/** Сетка линий на белом фоне заданной непрозрачности (0 — прозрачный). */
|
||||
/** Line grid on a white background of given opacity (0 = transparent). */
|
||||
export function drawGrid(
|
||||
width: number,
|
||||
height: number,
|
||||
|
||||
@@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest";
|
||||
import { gradientImage, noiseImage, solidImage } from "./generate";
|
||||
|
||||
describe("solidImage", () => {
|
||||
it("заливает весь холст заданным цветом", () => {
|
||||
it("fills whole canvas with given color", () => {
|
||||
const out = solidImage(2, 2, [10, 20, 30, 255]);
|
||||
expect(out.width).toBe(2);
|
||||
expect([...out.data]).toEqual(new Array(4).fill([10, 20, 30, 255]).flat());
|
||||
@@ -13,25 +13,25 @@ describe("solidImage", () => {
|
||||
[10, 0],
|
||||
[2.5, 10],
|
||||
[-1, 5],
|
||||
])("бросает ошибку на размерах %i x %i", (w, h) => {
|
||||
])("throws on sizes %i x %i", (w, h) => {
|
||||
expect(() => solidImage(w, h, [0, 0, 0, 255])).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("noiseImage", () => {
|
||||
it("детерминирован: одно зерно — одни байты", () => {
|
||||
it("deterministic: same seed - same bytes", () => {
|
||||
expect([...noiseImage(4, 4, 42).data]).toEqual([
|
||||
...noiseImage(4, 4, 42).data,
|
||||
]);
|
||||
});
|
||||
|
||||
it("разные зерна дают разные данные", () => {
|
||||
it("different seeds give different data", () => {
|
||||
const a = [...noiseImage(8, 8, 1).data];
|
||||
const b = [...noiseImage(8, 8, 2).data];
|
||||
expect(a).not.toEqual(b);
|
||||
});
|
||||
|
||||
it("альфа всегда непрозрачная", () => {
|
||||
it("alpha always opaque", () => {
|
||||
const data = noiseImage(3, 3, 7).data;
|
||||
for (let i = 3; i < data.length; i += 4) {
|
||||
expect(data[i]).toBe(255);
|
||||
@@ -40,7 +40,7 @@ describe("noiseImage", () => {
|
||||
});
|
||||
|
||||
describe("gradientImage", () => {
|
||||
it("горизонтальный градиент идёт от цвета A к цвету B", () => {
|
||||
it("horizontal gradient goes from color A to color B", () => {
|
||||
const out = gradientImage(
|
||||
3,
|
||||
1,
|
||||
@@ -54,7 +54,7 @@ describe("gradientImage", () => {
|
||||
expect(px(2)).toEqual([255, 255, 255, 255]);
|
||||
});
|
||||
|
||||
it("вертикальный градиент меняется по строкам", () => {
|
||||
it("vertical gradient changes by rows", () => {
|
||||
const out = gradientImage(
|
||||
1,
|
||||
2,
|
||||
|
||||
@@ -22,7 +22,7 @@ const square = () =>
|
||||
]);
|
||||
|
||||
describe("flip", () => {
|
||||
it("отражает по горизонтали (зеркало слева-направо)", () => {
|
||||
it("flips horizontally (left-right mirror)", () => {
|
||||
const out = flip(square(), "horizontal");
|
||||
expect(out.width).toBe(2);
|
||||
expect(out.height).toBe(2);
|
||||
@@ -31,14 +31,14 @@ describe("flip", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("отражает по вертикали (сверху-вниз)", () => {
|
||||
it("flips vertically (top-bottom)", () => {
|
||||
const out = flip(square(), "vertical");
|
||||
expect([...out.data]).toEqual([
|
||||
3, 3, 3, 3, 4, 4, 4, 4, 1, 1, 1, 1, 2, 2, 2, 2,
|
||||
]);
|
||||
});
|
||||
|
||||
it("не мутирует вход", () => {
|
||||
it("does not mutate input", () => {
|
||||
const img = square();
|
||||
flip(img, "horizontal");
|
||||
expect([...img.data]).toEqual([
|
||||
@@ -48,7 +48,7 @@ describe("flip", () => {
|
||||
});
|
||||
|
||||
describe("rotate90", () => {
|
||||
it("поворачивает неквадрат 2x3 на 90° по часовой", () => {
|
||||
it("rotates non-square 2x3 by 90 deg clockwise", () => {
|
||||
const rect = makeImage(2, 3, [
|
||||
[1, 1, 1, 1],
|
||||
[2, 2, 2, 2],
|
||||
@@ -65,21 +65,21 @@ describe("rotate90", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("turns=0 возвращает копию без изменений", () => {
|
||||
it("turns=0 returns unchanged copy", () => {
|
||||
const img = square();
|
||||
const out = rotate90(img, 0);
|
||||
expect(out).not.toBe(img);
|
||||
expect([...out.data]).toEqual([...img.data]);
|
||||
});
|
||||
|
||||
it("нормализует turns: 5 ≡ 1, -3 ≡ 1, 4 ≡ 0", () => {
|
||||
it("normalizes turns: 5 == 1, -3 == 1, 4 == 0", () => {
|
||||
const once = [...rotate90(square(), 1).data];
|
||||
expect([...rotate90(square(), 5).data]).toEqual(once);
|
||||
expect([...rotate90(square(), -3).data]).toEqual(once);
|
||||
expect([...rotate90(square(), 4).data]).toEqual([...square().data]);
|
||||
});
|
||||
|
||||
it("turns=2 на квадрате равно двойному отражению", () => {
|
||||
it("turns=2 on square equals double flip", () => {
|
||||
const out = rotate90(square(), 2);
|
||||
expect([...out.data]).toEqual([
|
||||
4, 4, 4, 4, 3, 3, 3, 3, 2, 2, 2, 2, 1, 1, 1, 1,
|
||||
@@ -101,7 +101,7 @@ describe("crop", () => {
|
||||
[9, 9, 9, 9],
|
||||
]);
|
||||
|
||||
it("вырезает центральную область 2x2 из 3x3", () => {
|
||||
it("cuts central 2x2 area from 3x3", () => {
|
||||
const out = crop(grid(), 1, 1, 2, 2);
|
||||
expect(out.width).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);
|
||||
expect(out.width).toBe(1);
|
||||
expect(out.height).toBe(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/);
|
||||
});
|
||||
});
|
||||
@@ -125,7 +125,7 @@ describe("crop", () => {
|
||||
describe("expandCanvas", () => {
|
||||
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);
|
||||
expect(out.width).toBe(5);
|
||||
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");
|
||||
expect(out.data[0]).toBe(255);
|
||||
expect(out.data[3]).toBe(255);
|
||||
@@ -144,7 +144,7 @@ describe("expandCanvas", () => {
|
||||
});
|
||||
|
||||
describe("tile", () => {
|
||||
it("повторяет изображение по сетке", () => {
|
||||
it("repeats image on grid", () => {
|
||||
const out = tile(makeImage(1, 1, [[9, 9, 9, 255]]), 3, 2);
|
||||
expect(out.width).toBe(3);
|
||||
expect(out.height).toBe(2);
|
||||
@@ -155,7 +155,7 @@ describe("tile", () => {
|
||||
});
|
||||
|
||||
describe("centerByAlpha", () => {
|
||||
it("вырезает непрозрачный блок и центрирует на прежнем холсте", () => {
|
||||
it("cuts opaque block and centers on same canvas", () => {
|
||||
const img = makeImage(3, 3, [
|
||||
[0, 0, 0, 0],
|
||||
[0, 0, 0, 0],
|
||||
@@ -175,7 +175,7 @@ describe("centerByAlpha", () => {
|
||||
expect(alphaAt(1, 1)).toBe(255);
|
||||
});
|
||||
|
||||
it("полностью прозрачное изображение возвращается без изменений", () => {
|
||||
it("fully transparent image returned unchanged", () => {
|
||||
const img = makeImage(2, 1, [
|
||||
[0, 0, 0, 0],
|
||||
[0, 0, 0, 0],
|
||||
@@ -193,12 +193,12 @@ describe("resize", () => {
|
||||
[255, 255, 255, 255],
|
||||
]);
|
||||
|
||||
it("совпадает с входом при тех же размерах", () => {
|
||||
it("matches input at same size", () => {
|
||||
const img = twoByTwo();
|
||||
expect([...resize(img, 2, 2).data]).toEqual([...img.data]);
|
||||
});
|
||||
|
||||
it("апскейл 2x2 -> 4x4 билинейно интерполирует", () => {
|
||||
it("upscale 2x2 -> 4x4 bilinear interpolation", () => {
|
||||
const out = resize(twoByTwo(), 4, 4);
|
||||
expect(out.width).toBe(4);
|
||||
expect(out.height).toBe(4);
|
||||
@@ -207,7 +207,7 @@ describe("resize", () => {
|
||||
expect(red.slice(4, 8)).toEqual([50, 72, 117, 139]);
|
||||
});
|
||||
|
||||
it("бросает ToolError на некорректные размеры", () => {
|
||||
it("throws ToolError on invalid sizes", () => {
|
||||
const img = twoByTwo();
|
||||
expect(() => resize(img, 0, 10)).toThrow(/errors\.sizeInt/);
|
||||
expect(() => resize(img, 10.5, 10)).toThrow(/errors\.sizeInt/);
|
||||
@@ -226,7 +226,7 @@ describe("splitToParts", () => {
|
||||
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);
|
||||
expect(parts).toHaveLength(4);
|
||||
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);
|
||||
expect(parts).toHaveLength(4);
|
||||
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);
|
||||
expect(parts).toHaveLength(2);
|
||||
expect(parts[0].width).toBe(3);
|
||||
@@ -308,7 +308,7 @@ describe("splitToParts", () => {
|
||||
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 parts = splitToParts(img, 2, 2);
|
||||
expect(parts[parts.length - 1].data[0]).toBe(14);
|
||||
@@ -316,7 +316,7 @@ describe("splitToParts", () => {
|
||||
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);
|
||||
expect(parts).toHaveLength(2);
|
||||
expect(parts[0].width).toBe(3);
|
||||
@@ -336,7 +336,7 @@ describe("splitToTileSize", () => {
|
||||
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);
|
||||
expect(parts).toHaveLength(4);
|
||||
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);
|
||||
expect(parts).toHaveLength(4);
|
||||
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);
|
||||
expect(parts).toHaveLength(1);
|
||||
expect(parts[0].width).toBe(4);
|
||||
|
||||
@@ -58,10 +58,9 @@ export function tile(
|
||||
}
|
||||
|
||||
/**
|
||||
* Разрезает изображение на сетку columns × rows строго равных частей.
|
||||
* Холст дополняется прозрачным до кратного размера (`ceil(width / cols)`),
|
||||
* поэтому весь исходный контент сохраняется. Порядок — row-major: сначала все
|
||||
* столбцы первой строки (part 1-1, 1-2, …), затем второй и т.д.
|
||||
* Splits the image into a columns x rows grid of strictly equal parts.
|
||||
* Canvas transparently padded to a multiple size, so all content survives.
|
||||
* Row-major order: row 1 columns first (part 1-1, 1-2, ...), then row 2, etc.
|
||||
*/
|
||||
export function splitToParts(
|
||||
img: PixelImage,
|
||||
@@ -87,7 +86,7 @@ export function splitToParts(
|
||||
return parts;
|
||||
}
|
||||
|
||||
/** Нарезка по размеру тайла: канвас дополняется прозрачным до кратности. */
|
||||
/** Split by tile size: canvas transparently padded to a multiple. */
|
||||
export function splitToTileSize(
|
||||
img: PixelImage,
|
||||
tileW: number,
|
||||
@@ -301,7 +300,7 @@ export type Anchor9 =
|
||||
| "bottom-center"
|
||||
| "bottom-right";
|
||||
|
||||
/** Границы контента: пиксели с альфой строго больше порога. Пустое изображение → null. */
|
||||
/** Content bounds: pixels with alpha strictly above threshold. Empty image -> null. */
|
||||
export function contentBounds(
|
||||
img: PixelImage,
|
||||
alphaThreshold = 0,
|
||||
@@ -324,14 +323,14 @@ export function contentBounds(
|
||||
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 {
|
||||
const b = contentBounds(img, alphaThreshold);
|
||||
if (!b) return crop(img, 0, 0, 1, 1);
|
||||
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(
|
||||
img: PixelImage,
|
||||
width: number,
|
||||
@@ -366,7 +365,7 @@ export function changeCanvasSize(
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Центральный кроп до соотношения сторон (ratio ≥ 1 = широкое). */
|
||||
/** Center crop to an aspect ratio (ratio >= 1 = wide). */
|
||||
export function cropToRatio(img: PixelImage, ratio: number): PixelImage {
|
||||
const current = img.width / img.height;
|
||||
if (current > ratio) {
|
||||
@@ -380,7 +379,7 @@ export function cropToRatio(img: PixelImage, ratio: number): PixelImage {
|
||||
return clonePixelImage(img);
|
||||
}
|
||||
|
||||
/** Вписывает в соотношение сторон, добавляя прозрачные поля. */
|
||||
/** Fits into an aspect ratio by adding transparent margins. */
|
||||
export function padToRatio(img: PixelImage, ratio: number): PixelImage {
|
||||
const current = img.width / img.height;
|
||||
let w = img.width;
|
||||
@@ -392,7 +391,7 @@ export function padToRatio(img: PixelImage, ratio: number): PixelImage {
|
||||
return changeCanvasSize(img, w, h, "center");
|
||||
}
|
||||
|
||||
/** Разворачивает изображение на 90°, если его ориентация не совпадает с целевой. Квадрат не трогает. */
|
||||
/** Rotates the image 90 degrees if its orientation differs from the target. Square untouched. */
|
||||
export function forceOrientation(
|
||||
img: PixelImage,
|
||||
target: "portrait" | "landscape",
|
||||
@@ -408,8 +407,8 @@ export function forceOrientation(
|
||||
}
|
||||
|
||||
/**
|
||||
* Симметричная копия: к выбранной стороне оригинала добавляется его зеркало.
|
||||
* axis vertical — зеркалим по вертикальной линии (ширина ×2), horizontal — по горизонтальной (высота ×2).
|
||||
* Symmetric copy: the chosen side of the original gets its mirror appended.
|
||||
* axis vertical mirrors across a vertical line (width x2), horizontal across a horizontal one (height x2).
|
||||
*/
|
||||
export function symmetricCopy(
|
||||
img: PixelImage,
|
||||
|
||||
@@ -13,23 +13,23 @@ describe("isSupportedImage", () => {
|
||||
"image/gif",
|
||||
"image/bmp",
|
||||
"image/x-icon",
|
||||
])("принимает %s", (type) => {
|
||||
])("accepts %s", (type) => {
|
||||
expect(isSupportedImage(new File([], "x", { type }))).toBe(true);
|
||||
});
|
||||
|
||||
it("отклоняет неподдерживаемый тип", () => {
|
||||
it("rejects unsupported type", () => {
|
||||
expect(
|
||||
isSupportedImage(new File([], "a.txt", { type: "text/plain" })),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("отклоняет файл без типа", () => {
|
||||
it("rejects file without type", () => {
|
||||
expect(isSupportedImage(new File([], "x"))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("unsupportedImageError", () => {
|
||||
it("ключ ошибки и тип файла в vars", () => {
|
||||
it("error key and file type in vars", () => {
|
||||
const err = unsupportedImageError(
|
||||
new File([], "a.txt", { type: "text/plain" }),
|
||||
);
|
||||
@@ -37,7 +37,7 @@ describe("unsupportedImageError", () => {
|
||||
expect(err.vars?.type).toBe("text/plain");
|
||||
});
|
||||
|
||||
it("пустой тип передаётся как unknown", () => {
|
||||
it("empty type passed as unknown", () => {
|
||||
const err = unsupportedImageError(new File([], "x"));
|
||||
expect(err.vars?.type).toBe("unknown");
|
||||
});
|
||||
@@ -45,7 +45,7 @@ describe("unsupportedImageError", () => {
|
||||
|
||||
describe("validateOutputQuality", () => {
|
||||
it.each(["image/jpeg", "image/webp"] as const)(
|
||||
"принимает границы 0 и 1 для %s",
|
||||
"accepts bounds 0 and 1 for %s",
|
||||
(mime) => {
|
||||
expect(() => validateOutputQuality(mime, 0)).not.toThrow();
|
||||
expect(() => validateOutputQuality(mime, 1)).not.toThrow();
|
||||
@@ -53,7 +53,7 @@ describe("validateOutputQuality", () => {
|
||||
);
|
||||
|
||||
it.each(["image/jpeg", "image/webp"] as const)(
|
||||
"отклоняет значение вне 0..1 для %s",
|
||||
"rejects value outside 0..1 for %s",
|
||||
(mime) => {
|
||||
expect(() => validateOutputQuality(mime, -0.01)).toThrow(
|
||||
"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/bmp", 2)).not.toThrow();
|
||||
});
|
||||
|
||||
+40
-4
@@ -1,5 +1,7 @@
|
||||
import { encodeBmpBytes } from "./bmp";
|
||||
import { findMaxColorsWithin, findQualityWithin } from "./compress";
|
||||
import { ToolError } from "./errors";
|
||||
import { quantizeImage } from "./quantize";
|
||||
import { type PixelImage } from "./types";
|
||||
|
||||
export type OutputMime =
|
||||
@@ -10,7 +12,7 @@ export interface OutputFormatOption {
|
||||
ext: string;
|
||||
label: string;
|
||||
supportsAlpha: boolean;
|
||||
/** Точные настройки формата в dropdown кнопки Download (расширяемо). */
|
||||
/** Fine-grained format settings in the Download button dropdown (extensible). */
|
||||
settings?: {
|
||||
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;
|
||||
|
||||
/** Варианты формата в кнопке Download (селектор рядом с ней). */
|
||||
/** Format options in the Download button (selector next to it). */
|
||||
export const OUTPUT_FORMATS: readonly OutputFormatOption[] = [
|
||||
{ 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> {
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = bitmap.width;
|
||||
@@ -77,7 +87,7 @@ async function decodeBitmap(bitmap: ImageBitmap): Promise<PixelImage> {
|
||||
}
|
||||
|
||||
export async function decodeFile(file: File): Promise<PixelImage> {
|
||||
const bitmap = await createImageBitmap(file);
|
||||
const bitmap = await toBitmap(file);
|
||||
try {
|
||||
return await decodeBitmap(bitmap);
|
||||
} finally {
|
||||
@@ -89,7 +99,7 @@ export async function decodeBytes(
|
||||
bytes: Uint8Array<ArrayBuffer>,
|
||||
): Promise<PixelImage> {
|
||||
const blob = new Blob([bytes]);
|
||||
const bitmap = await createImageBitmap(blob);
|
||||
const bitmap = await toBitmap(blob);
|
||||
try {
|
||||
return await decodeBitmap(bitmap);
|
||||
} 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 {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
diffMask,
|
||||
extractByColor,
|
||||
isGrayscaleish,
|
||||
luma01,
|
||||
@@ -14,26 +15,26 @@ const px = makeImage(2, 1, [
|
||||
]);
|
||||
|
||||
describe("isGrayscaleish / luma01", () => {
|
||||
it("серый распознаётся с допуском, цветной — нет", () => {
|
||||
it("gray recognized within tolerance, colored not", () => {
|
||||
expect(isGrayscaleish(10, 10, 10, 0)).toBe(true);
|
||||
expect(isGrayscaleish(12, 10, 11, 2)).toBe(true);
|
||||
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(0, 0, 0)).toBeCloseTo(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("renderPredicateMask", () => {
|
||||
it("binary: совпавшие белые на чёрном, альфа принудительная", () => {
|
||||
it("binary: matches white on black, alpha forced", () => {
|
||||
const out = renderPredicateMask(px, (r) => r > 200, { mode: "binary" });
|
||||
expect([...out.data.slice(0, 4)]).toEqual([255, 255, 255, 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, {
|
||||
mode: "highlight",
|
||||
color: "#0000ff",
|
||||
@@ -43,7 +44,7 @@ describe("renderPredicateMask", () => {
|
||||
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 out = renderPredicateMask(semi, () => true, {
|
||||
mode: "highlight",
|
||||
@@ -62,15 +63,38 @@ describe("rarityPredicate + extractByColor", () => {
|
||||
[0, 255, 0, 255],
|
||||
]);
|
||||
|
||||
it("уникальный (единичный) цвет находится, массовый — нет", () => {
|
||||
it("unique (single) color found, common not", () => {
|
||||
const pred = rarityPredicate(img, 1);
|
||||
expect(pred(0, 255, 0, 255)).toBe(true);
|
||||
expect(pred(255, 0, 0, 255)).toBe(false);
|
||||
});
|
||||
|
||||
it("extractByColor оставляет близкие и делает дальние прозрачными", () => {
|
||||
it("extractByColor keeps near and makes far transparent", () => {
|
||||
const out = extractByColor(px, "#0a0a0a", 5);
|
||||
expect(out.data[3]).toBe(0);
|
||||
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]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,15 +5,15 @@ import { createPixelImage } from "./types";
|
||||
export type MaskMode = "binary" | "highlight";
|
||||
|
||||
export interface MaskOptions {
|
||||
/** binary: белое/чёрное без альфы; highlight: подкрасить совпавшие пиксели цветом. */
|
||||
/** binary: white/black without alpha; highlight: tint matching pixels. */
|
||||
mode?: MaskMode;
|
||||
color?: string;
|
||||
opacityPercent?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Единый рендер масок: предикат решает, совпал ли пиксель;
|
||||
* режим определяет вид результата.
|
||||
* Unified mask renderer: the predicate decides whether a pixel matches;
|
||||
* the mode defines the look of the result.
|
||||
*/
|
||||
export function renderPredicateMask(
|
||||
img: PixelImage,
|
||||
@@ -25,7 +25,7 @@ export function renderPredicateMask(
|
||||
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;
|
||||
if (!highlight) {
|
||||
// Бинарная маска — непрозрачное белое-на-чёрном
|
||||
// Binary mask: opaque white-on-black
|
||||
for (let a = 3; a < out.data.length; a += 4) out.data[a] = 255;
|
||||
}
|
||||
for (let i = 0; i < img.data.length; i += 4) {
|
||||
@@ -86,8 +86,8 @@ export function luma01(r: number, g: number, b: number): number {
|
||||
}
|
||||
|
||||
/**
|
||||
* Оставляет пиксели, близкие к целевому цвету, остальное делает прозрачным.
|
||||
* Допуск — в процентах от максимального поканального разброса (255).
|
||||
* Keeps pixels close to the target color, makes the rest transparent.
|
||||
* Tolerance is a percentage of the max per-channel delta (255).
|
||||
*/
|
||||
export function extractByColor(
|
||||
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(
|
||||
img: PixelImage,
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
buildAlphaMask,
|
||||
closingImage,
|
||||
contourImage,
|
||||
contourMask,
|
||||
dilateImage,
|
||||
dilateMask,
|
||||
erodeImage,
|
||||
@@ -34,7 +35,7 @@ function toRows(mask: Uint8Array, w: number): string[] {
|
||||
}
|
||||
|
||||
describe("dilateMask", () => {
|
||||
it("одиночный пиксель r=1 превращается в плюс", () => {
|
||||
it("single pixel r=1 becomes a plus", () => {
|
||||
const out = dilateMask(
|
||||
maskFrom([".....", ".....", "..#..", ".....", "....."]),
|
||||
5,
|
||||
@@ -50,7 +51,7 @@ describe("dilateMask", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("r=2 даёт ромб радиуса 2", () => {
|
||||
it("r=2 yields diamond of radius 2", () => {
|
||||
const out = dilateMask(
|
||||
maskFrom([".....", ".....", "..#..", ".....", "....."]),
|
||||
5,
|
||||
@@ -68,12 +69,12 @@ describe("dilateMask", () => {
|
||||
});
|
||||
|
||||
describe("erodeMask", () => {
|
||||
it("сплошной объект во весь кадр не сжимается от границ", () => {
|
||||
it("full-frame solid object not shrunk from borders", () => {
|
||||
const solid = maskFrom(["#####", "#####", "#####", "#####", "#####"]);
|
||||
expect([...erodeMask(solid, 5, 5, 1)]).toEqual([...solid]);
|
||||
});
|
||||
|
||||
it("изолированный пиксель исчезает", () => {
|
||||
it("isolated pixel vanishes", () => {
|
||||
const out = erodeMask(
|
||||
maskFrom([".....", ".....", "..#..", ".....", "....."]),
|
||||
5,
|
||||
@@ -84,8 +85,8 @@ describe("erodeMask", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("opening/closing образы", () => {
|
||||
it("opening убирает отстоящий мусорный пиксель и сохраняет блок", () => {
|
||||
describe("opening/closing images", () => {
|
||||
it("opening removes stray noise pixel and keeps block", () => {
|
||||
const B = [10, 10, 10, 255];
|
||||
const T = [0, 0, 0, 0];
|
||||
const S = [200, 200, 200, 255];
|
||||
@@ -99,7 +100,7 @@ describe("opening/closing образы", () => {
|
||||
expect(at(4, 1)).toBe(0);
|
||||
});
|
||||
|
||||
it("closing заполняет одиночную прозрачную дыру чёрным", () => {
|
||||
it("closing fills single transparent hole with black", () => {
|
||||
const pixels: number[][] = [];
|
||||
for (let y = 0; y < 3; y++) {
|
||||
for (let x = 0; x < 3; x++) {
|
||||
@@ -112,8 +113,8 @@ describe("opening/closing образы", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("image-обёртки", () => {
|
||||
it("dilateImage сохраняет RGB содержимого, новые пиксели непрозрачны", () => {
|
||||
describe("image wrappers", () => {
|
||||
it("dilateImage keeps content RGB, new pixels opaque", () => {
|
||||
const T = [0, 0, 0, 0];
|
||||
const O = [200, 50, 25, 255];
|
||||
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);
|
||||
});
|
||||
|
||||
it("erodeImage не стирает объект у края кадра", () => {
|
||||
it("erodeImage does not erase object at frame edge", () => {
|
||||
const img = makeImage(2, 2, [
|
||||
[10, 20, 30, 255],
|
||||
[10, 20, 30, 255],
|
||||
@@ -147,7 +148,7 @@ describe("image-обёртки", () => {
|
||||
});
|
||||
|
||||
describe("strokeImage", () => {
|
||||
it("кольцо цвета обводки вокруг квадрата", () => {
|
||||
it("ring of stroke color around square", () => {
|
||||
const pixels: number[][] = [];
|
||||
for (let y = 0; y < 7; y++) {
|
||||
for (let x = 0; x < 7; x++) {
|
||||
@@ -169,7 +170,7 @@ describe("strokeImage", () => {
|
||||
});
|
||||
|
||||
describe("contourImage", () => {
|
||||
it("линия по краю квадрата, центр прозрачен", () => {
|
||||
it("line along square edge, center transparent", () => {
|
||||
const pixels: number[][] = [];
|
||||
for (let y = 0; y < 7; y++) {
|
||||
for (let x = 0; x < 7; x++) {
|
||||
@@ -189,3 +190,24 @@ describe("contourImage", () => {
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -167,6 +167,23 @@ export function contourImage(
|
||||
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(
|
||||
mask: Mask,
|
||||
w: number,
|
||||
|
||||
@@ -19,8 +19,8 @@ import {
|
||||
tetradicSet,
|
||||
} from "./palette";
|
||||
|
||||
describe("конвертация rgb↔hsl", () => {
|
||||
it("красный → hsl(0,100%,50%) и обратно", () => {
|
||||
describe("rgb<->hsl conversion", () => {
|
||||
it("red -> hsl(0,100%,50%) and back", () => {
|
||||
const hsl = rgbToHsl(hexToRgb("#ff0000"));
|
||||
expect(hsl.h).toBeCloseTo(0, 0);
|
||||
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");
|
||||
});
|
||||
|
||||
it("серые не имеют оттенка", () => {
|
||||
it("grays have no hue", () => {
|
||||
expect(rgbToHsl(hexToRgb("#808080")).s).toBe(0);
|
||||
});
|
||||
|
||||
it("круговой переход 360° возвращает исходный цвет", () => {
|
||||
it("360 deg wraparound returns original color", () => {
|
||||
const base = "#3b82f6";
|
||||
expect(
|
||||
rgbToHex(hslToRgb({ ...rgbToHsl(hexToRgb(base)), h: 720 + 30 })),
|
||||
@@ -40,8 +40,8 @@ describe("конвертация rgb↔hsl", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("гармонии", () => {
|
||||
it("complementary — пара с сдвигом 180°", () => {
|
||||
describe("harmonies", () => {
|
||||
it("complementary - pair shifted 180 deg", () => {
|
||||
const [a, b] = complementarySet("#ff0000");
|
||||
expect(a).toBe("#ff0000");
|
||||
const ha = rgbToHsl(hexToRgb(a)).h;
|
||||
@@ -49,7 +49,7 @@ describe("гармонии", () => {
|
||||
expect(Math.abs((hb - ha + 360) % 360)).toBeCloseTo(180, 0);
|
||||
});
|
||||
|
||||
it("triadic — три цвета через 120°", () => {
|
||||
it("triadic - three colors 120 deg apart", () => {
|
||||
const set = triadicSet("#ff0000");
|
||||
expect(set).toHaveLength(3);
|
||||
const hues = set.map((h) => rgbToHsl(hexToRgb(h)).h);
|
||||
@@ -57,18 +57,18 @@ describe("гармонии", () => {
|
||||
expect(hues[2] - hues[0]).toBeCloseTo(240, 0);
|
||||
});
|
||||
|
||||
it("tetradic — четыре цвета через 90°", () => {
|
||||
it("tetradic - four colors 90 deg apart", () => {
|
||||
const set = tetradicSet("#00ff00");
|
||||
expect(set).toHaveLength(4);
|
||||
});
|
||||
|
||||
it("analogous симметричен вокруг базы", () => {
|
||||
it("analogous symmetric around base", () => {
|
||||
const set = analogousSet("#0000ff", 30, 5);
|
||||
expect(set).toHaveLength(5);
|
||||
expect(set[2]).toBe(normalizeHex("#0000ff"));
|
||||
});
|
||||
|
||||
it("monochromatic держит оттенок, меняет светлоту", () => {
|
||||
it("monochromatic keeps hue, varies lightness", () => {
|
||||
const set = monochromaticSet("#ff8800", 5, 60);
|
||||
expect(set).toHaveLength(5);
|
||||
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));
|
||||
});
|
||||
|
||||
it("shades — тёмный край темнее базы", () => {
|
||||
it("shades - dark edge darker than base", () => {
|
||||
const set = shadeSet("#88cc44", 4, 70);
|
||||
const lights = set.map((h) => rgbToHsl(hexToRgb(h)).l);
|
||||
expect(lights[lights.length - 1]).toBeLessThan(lights[0]);
|
||||
@@ -85,30 +85,30 @@ describe("гармонии", () => {
|
||||
});
|
||||
|
||||
describe("parseHexList / mixColors / sortPalette", () => {
|
||||
it("парсит список и отбрасывает мусор и токены без решётки", () => {
|
||||
it("parses list, drops junk and hash-less tokens", () => {
|
||||
expect(parseHexList("#ff0000, #00FF00 ; 00ff00 zz")).toEqual([
|
||||
"#ff0000",
|
||||
"#00ff00",
|
||||
]);
|
||||
});
|
||||
|
||||
it("пустой валидный список бросает badHex", () => {
|
||||
expect(() => parseHexList("нет цветов")).toThrow(/errors\.badHex/);
|
||||
it("empty valid list throws badHex", () => {
|
||||
expect(() => parseHexList("no colors")).toThrow(/errors\.badHex/);
|
||||
});
|
||||
|
||||
it("mixColors — среднее компонент", () => {
|
||||
it("mixColors - component average", () => {
|
||||
expect(mixColors(["#000000", "#ffffff"])).toBe("#808080");
|
||||
});
|
||||
|
||||
it("sortPalette по luma ставит тёмные раньше", () => {
|
||||
it("sortPalette by luma puts dark first", () => {
|
||||
const sorted = sortPalette(["#ffffff", "#000000", "#808080"], "luma");
|
||||
expect(sorted[0]).toBe("#000000");
|
||||
expect(sorted[2]).toBe("#ffffff");
|
||||
});
|
||||
});
|
||||
|
||||
describe("рендеры", () => {
|
||||
it("renderSwatches strip: колонки по цветам", () => {
|
||||
describe("renders", () => {
|
||||
it("renderSwatches strip: columns by color", () => {
|
||||
const img = renderSwatches(["#ff0000", "#00ff00"], 200, "strip");
|
||||
expect(img.width).toBe(200);
|
||||
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);
|
||||
});
|
||||
|
||||
it("renderSwatches grid: квадратные ячейки", () => {
|
||||
it("renderSwatches grid: square cells", () => {
|
||||
const img = renderSwatches(["#111111", "#222222", "#333333"], 120, "grid");
|
||||
expect(img.width).toBe(120);
|
||||
expect(img.height).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("renderWheel: углы прозрачны, центр непрозрачен", () => {
|
||||
it("renderWheel: corners transparent, center opaque", () => {
|
||||
const size = 101;
|
||||
const img = renderWheel(size, 50);
|
||||
expect(img.data[3]).toBe(0);
|
||||
@@ -134,7 +134,7 @@ describe("рендеры", () => {
|
||||
expect(img.data[c + 3]).toBe(255);
|
||||
});
|
||||
|
||||
it("renderBlend: левый край = a, правый = b", () => {
|
||||
it("renderBlend: left edge = a, right = b", () => {
|
||||
const img = renderBlend("#000000", "#ffffff", 100);
|
||||
expect(img.data[0]).toBe(0);
|
||||
const last = 99 * 4;
|
||||
|
||||
@@ -25,7 +25,7 @@ export function rgbToHex({ r, g, b }: Rgb): string {
|
||||
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 {
|
||||
const rn = r / 255;
|
||||
const gn = g / 255;
|
||||
@@ -184,7 +184,7 @@ function clamp01(v: number): number {
|
||||
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(
|
||||
colors: string[],
|
||||
width: number,
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
import { makeImage } from "./test-helpers";
|
||||
|
||||
describe("pixelate", () => {
|
||||
it("блок усредняется: шахматка 2×2 с блоком 2 → один цвет", () => {
|
||||
it("block averaged: 2x2 checker with block 2 -> one color", () => {
|
||||
const img = makeImage(2, 2, [
|
||||
[0, 0, 0, 255],
|
||||
[200, 200, 200, 255],
|
||||
@@ -23,14 +23,14 @@ describe("pixelate", () => {
|
||||
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]));
|
||||
expect([...pixelate(img, 2).data]).toEqual([...img.data]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("shuffleBlocks / addNoise — детерминизм по seed", () => {
|
||||
it("тот же seed даёт то же перемешивание", () => {
|
||||
describe("shuffleBlocks / addNoise - seed determinism", () => {
|
||||
it("same seed gives same shuffle", () => {
|
||||
const img = makeImage(4, 1, [
|
||||
[10, 0, 0, 255],
|
||||
[20, 0, 0, 255],
|
||||
@@ -42,7 +42,7 @@ describe("shuffleBlocks / addNoise — детерминизм по seed", () =>
|
||||
expect([...a.data]).toEqual([...b.data]);
|
||||
});
|
||||
|
||||
it("мультимножество пикселей сохраняется (перестановка)", () => {
|
||||
it("pixel multiset preserved (permutation)", () => {
|
||||
const img = makeImage(4, 1, [
|
||||
[10, 0, 0, 255],
|
||||
[20, 0, 0, 255],
|
||||
@@ -55,7 +55,7 @@ describe("shuffleBlocks / addNoise — детерминизм по seed", () =>
|
||||
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 a = addNoise(img, 20, "mono", 5);
|
||||
const b = addNoise(img, 20, "mono", 5);
|
||||
@@ -65,8 +65,8 @@ describe("shuffleBlocks / addNoise — детерминизм по seed", () =>
|
||||
});
|
||||
|
||||
describe("featherAlpha", () => {
|
||||
it("жёсткий край получает промежуточные альфы", () => {
|
||||
// левая половина непрозрачная, правая прозрачная
|
||||
it("hard edge gets intermediate alphas", () => {
|
||||
// left half opaque, right transparent
|
||||
const img = makeImage(6, 1, [
|
||||
[255, 0, 0, 255],
|
||||
[255, 0, 0, 255],
|
||||
@@ -83,7 +83,7 @@ describe("featherAlpha", () => {
|
||||
});
|
||||
|
||||
describe("defringe", () => {
|
||||
it("полупрозрачному пикселю берётся RGB от соседнего непрозрачного", () => {
|
||||
it("semi-transparent pixel takes RGB from opaque neighbor", () => {
|
||||
const img = makeImage(2, 1, [
|
||||
[250, 250, 250, 255],
|
||||
[255, 0, 0, 128],
|
||||
@@ -91,10 +91,10 @@ describe("defringe", () => {
|
||||
const out = defringe(img, 2);
|
||||
expect(out.data[4]).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, [
|
||||
[250, 250, 250, 255],
|
||||
[0, 0, 0, 0],
|
||||
@@ -105,7 +105,7 @@ describe("defringe", () => {
|
||||
});
|
||||
|
||||
describe("silhouette", () => {
|
||||
it("видимые пиксели заливаются цветом, ниже порога — прозрачность", () => {
|
||||
it("visible pixels filled with color, below threshold - transparent", () => {
|
||||
const img = makeImage(2, 1, [
|
||||
[123, 45, 67, 255],
|
||||
[123, 45, 67, 10],
|
||||
@@ -117,7 +117,7 @@ describe("silhouette", () => {
|
||||
});
|
||||
|
||||
describe("mulberry32", () => {
|
||||
it("последовательность детерминирована", () => {
|
||||
it("sequence is deterministic", () => {
|
||||
const a = mulberry32(42);
|
||||
const b = mulberry32(42);
|
||||
expect([a(), a(), a()]).toEqual([b(), b(), b()]);
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { PixelImage } from "./types";
|
||||
import { createPixelImage } from "./types";
|
||||
import { gaussianBlur } from "./convolution";
|
||||
|
||||
/** Детерминированный ГПСЧ (mulberry32): одинаковый seed — одинаковый результат. */
|
||||
/** Deterministic PRNG (mulberry32): same seed gives the same result. */
|
||||
export function mulberry32(seed: number): () => number {
|
||||
let a = seed >>> 0;
|
||||
return () => {
|
||||
@@ -19,7 +19,7 @@ function clampByte(v: number): number {
|
||||
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 {
|
||||
const bs = Math.max(1, Math.round(blockSize));
|
||||
const out = createPixelImage(img.width, img.height);
|
||||
@@ -58,7 +58,7 @@ export function pixelate(img: PixelImage, blockSize: number): PixelImage {
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Перемешивает блоки blockSize×Blocksize между собой детерминированно по seed. */
|
||||
/** Shuffles blockSize x blockSize blocks deterministically per seed. */
|
||||
export function shuffleBlocks(
|
||||
img: PixelImage,
|
||||
blockSize: number,
|
||||
@@ -98,7 +98,7 @@ export function shuffleBlocks(
|
||||
|
||||
export type NoiseMode = "mono" | "color";
|
||||
|
||||
/** Зерно: amountPercent — сила отклонения от оригинала. Детерминировано по seed. */
|
||||
/** Grain: amountPercent is the deviation strength from the original. Deterministic per seed. */
|
||||
export function addNoise(
|
||||
img: PixelImage,
|
||||
amountPercent: number,
|
||||
@@ -128,7 +128,7 @@ export function addNoise(
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Размытие только альфа-канала: мягкие края при неизменном цвете. */
|
||||
/** Blurs only the alpha channel: soft edges with unchanged color. */
|
||||
export function featherAlpha(img: PixelImage, radius: number): PixelImage {
|
||||
const gray = createPixelImage(img.width, img.height);
|
||||
for (let i = 0; i < img.data.length; i += 4) {
|
||||
@@ -149,8 +149,9 @@ export function featherAlpha(img: PixelImage, radius: number): PixelImage {
|
||||
}
|
||||
|
||||
/**
|
||||
* Убирает цветную кайму на полупрозрачных краях: RGB полупрозрачного пикселя
|
||||
* заменяется цветом ближайшего полностью непрозрачного соседа в пределах radius.
|
||||
* Removes the color fringe on semi-transparent edges: the RGB of a
|
||||
* semi-transparent pixel is replaced by the color of the nearest fully
|
||||
* opaque neighbor within radius.
|
||||
*/
|
||||
export function defringe(img: PixelImage, radius: number): PixelImage {
|
||||
const out = createPixelImage(img.width, img.height);
|
||||
@@ -184,7 +185,7 @@ export function defringe(img: PixelImage, radius: number): PixelImage {
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Силуэт: все видимые пиксели заливаются одним цветом, альфа сохраняется. */
|
||||
/** Silhouette: all visible pixels filled with one color, alpha preserved. */
|
||||
export function silhouette(
|
||||
img: PixelImage,
|
||||
colorHex: string,
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
import { makeImage } from "./test-helpers";
|
||||
|
||||
describe("medianCutPalette", () => {
|
||||
it("два явных кластера при k=2 дают сами цвета", () => {
|
||||
it("two clear clusters at k=2 yield the colors themselves", () => {
|
||||
const img = makeImage(4, 1, [
|
||||
[0, 0, 0, 255],
|
||||
[0, 0, 0, 255],
|
||||
@@ -21,14 +21,14 @@ describe("medianCutPalette", () => {
|
||||
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]));
|
||||
expect(medianCutPalette(empty, 4)).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("quantizeImage / ditherImage", () => {
|
||||
it("квантование укладывает пиксели в палитру, прозрачность сохраняется", () => {
|
||||
it("quantization fits pixels into palette, keeps transparency", () => {
|
||||
const img = makeImage(3, 1, [
|
||||
[10, 10, 10, 255],
|
||||
[250, 250, 250, 255],
|
||||
@@ -36,7 +36,7 @@ describe("quantizeImage / ditherImage", () => {
|
||||
]);
|
||||
const { image, palette } = quantizeImage(img, 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++) {
|
||||
const i = x * 4;
|
||||
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 out = ditherImage(gray, 2, "floyd-steinberg", ["#000000", "#ffffff"]);
|
||||
let hasDark = false;
|
||||
@@ -60,14 +60,14 @@ describe("quantizeImage / ditherImage", () => {
|
||||
expect(hasLight).toBe(true);
|
||||
});
|
||||
|
||||
it("bayer детерминирован", () => {
|
||||
it("bayer is deterministic", () => {
|
||||
const gray = makeImage(8, 8, new Array(64).fill([128, 128, 128, 255]));
|
||||
const a = ditherImage(gray, 2, "bayer");
|
||||
const b = ditherImage(gray, 2, "bayer");
|
||||
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 out = ditherImage(empty, 4, "bayer");
|
||||
expect(out.data[3]).toBe(0);
|
||||
@@ -75,7 +75,7 @@ describe("quantizeImage / ditherImage", () => {
|
||||
});
|
||||
|
||||
describe("mapToNearest", () => {
|
||||
it("маппинг на ближайший из списка", () => {
|
||||
it("maps to nearest from list", () => {
|
||||
const img = makeImage(2, 1, [
|
||||
[10, 10, 10, 255],
|
||||
[240, 240, 240, 255],
|
||||
|
||||
@@ -25,7 +25,7 @@ function nearestIndex(palette: Rgb[], r: number, g: number, b: number): number {
|
||||
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[] {
|
||||
const maxColors = Math.max(2, Math.min(64, Math.round(k)));
|
||||
const pixels: Rgb[] = [];
|
||||
@@ -102,7 +102,7 @@ export interface QuantizeResult {
|
||||
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 {
|
||||
const palette = medianCutPalette(img, k);
|
||||
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) };
|
||||
}
|
||||
|
||||
/** Маппинг каждого пикселя на ближайший цвет пользовательского списка. */
|
||||
/** Maps each pixel to the nearest color of the user-provided list. */
|
||||
export function mapToNearest(
|
||||
img: PixelImage,
|
||||
paletteHexes: string[],
|
||||
@@ -155,8 +155,8 @@ const BAYER_4 = [
|
||||
export type DitherPattern = "floyd-steinberg" | "bayer";
|
||||
|
||||
/**
|
||||
* Дизеринг к палитре из k цветов (median-cut) или к явно заданному списку hex.
|
||||
* floyd-steinberg — распространение ошибки; bayer — упорядоченный 4×4.
|
||||
* Dithering to a k-color palette (median-cut) or an explicit hex list.
|
||||
* floyd-steinberg: error diffusion; bayer: ordered 4x4.
|
||||
*/
|
||||
export function ditherImage(
|
||||
img: PixelImage,
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
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";
|
||||
|
||||
describe("тесты фигур", () => {
|
||||
it("круг: центр внутри, угол снаружи", () => {
|
||||
describe("shape predicates", () => {
|
||||
it("circle: center inside, corner outside", () => {
|
||||
const t = circleTest(0.5);
|
||||
expect(t(0, 0)).toBe(true);
|
||||
expect(t(0.49, 0)).toBe(true);
|
||||
@@ -11,46 +18,46 @@ describe("тесты фигур", () => {
|
||||
expect(t(0.4, 0.4)).toBe(false);
|
||||
});
|
||||
|
||||
it("прямоугольник: полуоси независимы", () => {
|
||||
it("box: semi-axes independent", () => {
|
||||
const t = boxTest(0.5, 0.25);
|
||||
expect(t(0.45, 0.2)).toBe(true);
|
||||
expect(t(0.2, 0.3)).toBe(false);
|
||||
});
|
||||
|
||||
it("звезда: луч внутри дальше впадины", () => {
|
||||
it("star: ray inside further than valley", () => {
|
||||
const t = starTest(5, 0.5, 1, 0);
|
||||
expect(t(0.9, 0)).toBe(true); // вдоль луча (θ=0)
|
||||
const valleyAngle = Math.PI / 5; // середина между лучами
|
||||
expect(t(0.9, 0)).toBe(true); // along the ray (theta=0)
|
||||
const valleyAngle = Math.PI / 5; // midway between rays
|
||||
expect(t(Math.cos(valleyAngle) * 0.8, Math.sin(valleyAngle) * 0.8)).toBe(
|
||||
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 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 px = 0.55;
|
||||
expect(a(px * Math.cos(rad), px * Math.sin(rad))).toBe(true); // край 0.6
|
||||
expect(b(px * Math.cos(rad), px * Math.sin(rad))).toBe(false); // край 0.4
|
||||
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); // edge 0.4
|
||||
});
|
||||
});
|
||||
|
||||
describe("renderShape", () => {
|
||||
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));
|
||||
let opaque = 0;
|
||||
for (let i = 3; i < out.data.length; i += 4)
|
||||
if (out.data[i] === 255) opaque++;
|
||||
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(
|
||||
makeImage(2, 2, new Array(4).fill([255, 255, 255, 255])),
|
||||
circleTest(0.7),
|
||||
@@ -60,9 +67,9 @@ describe("renderShape", () => {
|
||||
expect(shifted.data[4 + 3]).toBe(255);
|
||||
});
|
||||
|
||||
// Регрессия старого бага square-mask: полный размер без смещения обязан
|
||||
// сохранять квадратную картинку целиком.
|
||||
it("квадрат 100% без смещения сохраняет всё квадратное изображение", () => {
|
||||
// Regression of old square-mask bug: full size without offset must
|
||||
// keep the whole square image.
|
||||
it("100% box without offset keeps whole square image", () => {
|
||||
const out = renderShape(
|
||||
makeImage(4, 4, new Array(16).fill([255, 255, 255, 255])),
|
||||
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);
|
||||
});
|
||||
|
||||
// Смещение — доля свободного места: ±0.5 прижимает фигуру к краю при любом
|
||||
// её размере, фигура не уезжает за холст.
|
||||
it("максимальное смещение прижимает фигуру к краю", () => {
|
||||
// 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("max offset pins shape to edge", () => {
|
||||
const out = renderShape(
|
||||
makeImage(4, 4, new Array(16).fill([255, 255, 255, 255])),
|
||||
boxTest(0.25, 0.25),
|
||||
@@ -88,3 +95,25 @@ describe("renderShape", () => {
|
||||
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
@@ -3,15 +3,15 @@ import { createPixelImage } from "./types";
|
||||
|
||||
export type ShapeTest = (nx: number, ny: number) => boolean;
|
||||
|
||||
/** Полуразмер фигуры в нормированных координатах теста, по осям. */
|
||||
/** Half-size of the shape in normalized test coordinates, per axis. */
|
||||
export interface ShapeExtent {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Координаты теста: нормированные к половине меньшей стороны изображения,
|
||||
* центр в (0,0), ось Y вниз как в пикселях.
|
||||
* Test coordinates: normalized to half of the image's smaller side,
|
||||
* center at (0,0), Y axis pointing down as in pixels.
|
||||
*/
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
/** Звезда с points лучами; innerFrac — радиус впадин в долях внешнего радиуса. */
|
||||
/** Star with `points` rays; innerFrac is the valley radius as a fraction of the outer radius. */
|
||||
export function starTest(
|
||||
points: number,
|
||||
innerFrac: number,
|
||||
@@ -36,13 +36,13 @@ export function starTest(
|
||||
const r = Math.hypot(nx, ny);
|
||||
if (r > outerFrac) return false;
|
||||
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;
|
||||
return r <= edge;
|
||||
};
|
||||
}
|
||||
|
||||
/** Волнистый круг: радиус модулируется синусом с частотой waves. */
|
||||
/** Wavy circle: radius modulated by a sine of frequency `waves`. */
|
||||
export function wavyTest(
|
||||
baseFrac: number,
|
||||
amplitudeFrac: number,
|
||||
@@ -59,11 +59,30 @@ export function wavyTest(
|
||||
}
|
||||
|
||||
/**
|
||||
* Вырезает фигуру из изображения: внутри фигуры сохраняются исходные пиксели
|
||||
* (с их альфой), снаружи альфа обнуляется. Смещение задаётся в долях свободного
|
||||
* места по каждой оси: ±0.5 прижимает фигуру к краю независимо от её размера,
|
||||
* поэтому фигура никогда не уезжает за холст. Свободное место считается от
|
||||
* extent (полуразмера фигуры); без extent смещение считается от точки.
|
||||
* 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
|
||||
* edge regardless of size. Free space derives from extent (half-size).
|
||||
*/
|
||||
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(
|
||||
img: PixelImage,
|
||||
@@ -73,11 +92,13 @@ export function renderShape(
|
||||
extent: ShapeExtent = { x: 0, y: 0 },
|
||||
): PixelImage {
|
||||
const out = createPixelImage(img.width, img.height);
|
||||
const minDim = Math.min(img.width, img.height);
|
||||
const freeX = Math.max(0, img.width / (2 * minDim) - extent.x);
|
||||
const freeY = Math.max(0, img.height / (2 * minDim) - extent.y);
|
||||
const cx = img.width / 2 + offsetXFrac * 2 * freeX * minDim;
|
||||
const cy = img.height / 2 + offsetYFrac * 2 * freeY * minDim;
|
||||
const { cx, cy, minDim } = shapeOrigin(
|
||||
img.width,
|
||||
img.height,
|
||||
offsetXFrac,
|
||||
offsetYFrac,
|
||||
extent,
|
||||
);
|
||||
for (let y = 0; y < img.height; y++) {
|
||||
for (let x = 0; x < img.width; x++) {
|
||||
const di = (y * img.width + x) * 4;
|
||||
@@ -90,3 +111,34 @@ export function renderShape(
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { hexToPixels, pixelsToHex } from "./text";
|
||||
import { makeImage } from "./test-helpers";
|
||||
|
||||
describe("pixelsToHex", () => {
|
||||
it("форматирует пиксели как rrggbbaa построчно", () => {
|
||||
it("formats pixels as rrggbbaa row by row", () => {
|
||||
const out = pixelsToHex(
|
||||
makeImage(2, 2, [
|
||||
[255, 0, 0, 255],
|
||||
@@ -17,7 +17,7 @@ describe("pixelsToHex", () => {
|
||||
});
|
||||
|
||||
describe("hexToPixels", () => {
|
||||
it("обратим к pixelsToHex", () => {
|
||||
it("inverse of pixelsToHex", () => {
|
||||
const source = makeImage(3, 1, [
|
||||
[1, 2, 3, 4],
|
||||
[250, 251, 252, 253],
|
||||
@@ -26,7 +26,7 @@ describe("hexToPixels", () => {
|
||||
expect(hexToPixels(pixelsToHex(source), 3)).toEqual(source);
|
||||
});
|
||||
|
||||
it("допускает произвольные переводы строк и регистр", () => {
|
||||
it("tolerates arbitrary newlines and case", () => {
|
||||
const out = hexToPixels("FF0000FF\n\n00FF0080 00000080", 1);
|
||||
expect(out.width).toBe(1);
|
||||
expect(out.height).toBe(3);
|
||||
@@ -36,10 +36,10 @@ describe("hexToPixels", () => {
|
||||
});
|
||||
|
||||
it.each([
|
||||
["ff0000", "битые токены"],
|
||||
["ff0000ff ff0000ff ff0000ff", "не делится на ширину"],
|
||||
["", "пустой ввод"],
|
||||
])("бросает понятную ошибку: %s (%s)", (input) => {
|
||||
["ff0000", "broken tokens"],
|
||||
["ff0000ff ff0000ff ff0000ff", "not divisible by width"],
|
||||
["", "empty input"],
|
||||
])("throws clear error: %s (%s)", (input) => {
|
||||
expect(() => hexToPixels(input, 2)).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,7 +4,7 @@ import { anchorOrigin, tileGrid, wrapText } from "./textdraw";
|
||||
const measure = (s: string) => s.length * 10;
|
||||
|
||||
describe("anchorOrigin", () => {
|
||||
it("углы и отступ считаются от краёв", () => {
|
||||
it("corners and padding measured from edges", () => {
|
||||
expect(anchorOrigin("top-left", 100, 50, 400, 300, 30)).toEqual({
|
||||
x: 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({
|
||||
x: 150.5,
|
||||
y: 125.5,
|
||||
@@ -40,28 +40,28 @@ describe("anchorOrigin", () => {
|
||||
});
|
||||
|
||||
describe("wrapText", () => {
|
||||
it("жадно набирает строки в пределах ширины", () => {
|
||||
// measure: 10px за символ → строка ≤ 120px = 12 символов
|
||||
expect(wrapText("один два три четыре пять", 120, measure)).toEqual([
|
||||
"один два три",
|
||||
"четыре пять",
|
||||
it("greedily fills lines within width", () => {
|
||||
// measure: 10px per char -> line <= 120px = 12 chars
|
||||
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(
|
||||
wrapText("короткое сверхдлинноеслово без переносов", 90, measure),
|
||||
).toEqual(["короткое", "сверхдлинноеслово", "без", "переносов"]);
|
||||
wrapText("giraffes superlongwordhere cat crocodile", 90, measure),
|
||||
).toEqual(["giraffes", "superlongwordhere", "cat", "crocodile"]);
|
||||
});
|
||||
|
||||
it("пустой и пробельный текст дают пустой массив", () => {
|
||||
it("empty and whitespace text give empty array", () => {
|
||||
expect(wrapText("", 100, measure)).toEqual([]);
|
||||
expect(wrapText(" \n\t ", 100, measure)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("tileGrid", () => {
|
||||
it("стабильная сетка с шагом и центрированием", () => {
|
||||
it("stable grid with spacing and centering", () => {
|
||||
const pts = tileGrid(200, 200, 0, 60, 60, 80, 24);
|
||||
expect(pts.length).toBeGreaterThan(0);
|
||||
const xs = new Set(pts.map((p) => p.x));
|
||||
@@ -70,13 +70,13 @@ describe("tileGrid", () => {
|
||||
expect(ys.size).toBeGreaterThan(1);
|
||||
});
|
||||
|
||||
it("кап защищает от гигантского количества плиток", () => {
|
||||
it("cap protects against huge tile count", () => {
|
||||
const pts = tileGrid(4000, 4000, 45, 8, 8, 100, 40);
|
||||
expect(pts.length).toBeLessThanOrEqual(2500);
|
||||
expect(pts.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("обычные входные данные не триггерят кап", () => {
|
||||
it("normal input does not trigger cap", () => {
|
||||
const pts = tileGrid(800, 600, 30, 140, 90, 160, 40);
|
||||
expect(pts.length).toBeLessThanOrEqual(2500);
|
||||
expect(pts.length).toBeGreaterThan(4);
|
||||
|
||||
@@ -10,8 +10,8 @@ export type Position9 =
|
||||
| "bottom-right";
|
||||
|
||||
/**
|
||||
* Левый верхний угол контента размером contentW×contentH при размещении
|
||||
* на холсте cw×ch с отступом margin согласно 9-позиционной сетке.
|
||||
* Top-left corner of content sized contentW x contentH placed on a cw x ch
|
||||
* canvas with margin, per the 9-position grid.
|
||||
*/
|
||||
export function anchorOrigin(
|
||||
position: Position9,
|
||||
@@ -47,8 +47,8 @@ export function anchorOrigin(
|
||||
}
|
||||
|
||||
/**
|
||||
* Жадный перенос текста по словам под ширину maxWidth.
|
||||
* measure — инъекция измерителя ширины строки. Пустой текст → пустой массив строк.
|
||||
* Greedy word-wrap of text to maxWidth.
|
||||
* measure is an injected line-width meter. Empty text -> empty line array.
|
||||
*/
|
||||
export function wrapText(
|
||||
text: string,
|
||||
@@ -80,10 +80,9 @@ export type TilePoint = { x: number; y: number };
|
||||
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(
|
||||
cw: number,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user