Compare commits

...
8 Commits
141 changed files with 3081 additions and 1292 deletions
+10
View File
@@ -2,6 +2,16 @@
All notable changes to this project will be documented in this file. See [conventional commits](https://www.conventionalcommits.org/) for commit guidelines.
- - -
## [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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -1,6 +1,6 @@
{
"name": "easy-png-tools",
"version": "0.4.0",
"version": "0.5.0",
"description": "",
"main": "index.js",
"scripts": {
+2 -2
View File
@@ -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],
+8
View File
@@ -0,0 +1,8 @@
import { ru } from "../../src/lib/i18n/ru";
/** RU page title from the dictionary; RU text is never hardcoded in specs. */
export function ruPageTitle(slug: string): string {
const title = ru.pages?.[slug]?.title;
if (!title) throw new Error(`ru dictionary has no pages.${slug}.title`);
return title;
}
+4 -4
View File
@@ -76,8 +76,8 @@ export async function renderText(page: Page, value: string): Promise<void> {
}
export async function uploadImage(page: Page, file: SourceFile): Promise<void> {
// На странице может быть два 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
View File
@@ -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");
+3 -3
View File
@@ -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);
+1 -1
View 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 () => {
+7 -7
View File
@@ -46,7 +46,7 @@ test("resize-png keeps user-set size when another image is uploaded", async ({
await uploadImage(page, opaquePng);
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
View File
@@ -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);
+28
View File
@@ -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(", "),
},
});
}
},
};
},
};
+9 -13
View File
@@ -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) {
+4 -14
View File
@@ -16,31 +16,21 @@ export const SIZE_PROPS =
/^(width|height|min-width|max-width|min-height|max-height|padding|padding-top|padding-right|padding-bottom|padding-left|margin|margin-top|margin-right|margin-bottom|margin-left|gap|column-gap|row-gap|top|right|bottom|left|inset|font|font-size|letter-spacing|word-spacing|line-height|border-radius|border-top-left-radius|border-top-right-radius|border-bottom-left-radius|border-bottom-right-radius|border-width|border-top-width|border-right-width|border-bottom-width|border-left-width|flex-basis|background-size|background-position|border-spacing|grid-template-columns|grid-template-rows)$/;
// =====================================================================
// 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();
+9 -33
View File
@@ -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;
+3 -7
View File
@@ -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";
+3 -6
View File
@@ -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
View File
@@ -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
View File
@@ -1,7 +1,7 @@
{
"name": "web",
"private": true,
"version": "0.4.0",
"version": "0.5.0",
"type": "module",
"scripts": {
"dev": "vite dev",
+3 -3
View File
@@ -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,
+3 -3
View File
@@ -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 -12
View File
@@ -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 = {
+4 -13
View File
@@ -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";
+4 -4
View File
@@ -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" });
+3 -5
View File
@@ -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";
+2 -2
View File
@@ -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(");
+2 -5
View File
@@ -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 -1
View File
@@ -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.
+1 -1
View File
@@ -7,7 +7,7 @@ export type PreviewGroup = {
};
/**
* Preview-каталог: все страницы, сгруппированные по категориям.
* Preview catalog: all pages grouped by category.
*/
export const PREVIEW_GROUPS: PreviewGroup[] = CATEGORY_IDS.map((category) => ({
id: category,
+4 -8
View File
@@ -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[];
+153
View File
@@ -0,0 +1,153 @@
import { formatStamp } from "./core/datefmt";
import type { OutputMime } from "./core/io";
import type { PixelImage } from "./core/types";
import { t } from "./i18n/t";
import {
CHAIN_VERSION,
parseChain,
serializeSteps,
type ChainStep,
} from "./pipeline.svelte";
export interface NamedChain {
id: string;
name: string;
updatedAt: number;
steps: ChainStep[];
}
const STORE_KEY = "chains:named";
function storage(): Storage | null {
return typeof localStorage === "undefined" ? null : localStorage;
}
export function autoChainName(date = new Date()): string {
return t("savedChains.autoName", {
stamp: formatStamp(date, "YYYY-MM-DD hh:mm"),
});
}
/**
* Lenient per entry: one broken chain must not kill the rest of the list.
* Steps reuse the same serialized shape and validation as parseChain.
*/
export function parseNamedChains(data: unknown): NamedChain[] {
if (
!data ||
typeof data !== "object" ||
(data as { version?: unknown }).version !== CHAIN_VERSION ||
!Array.isArray((data as { chains?: unknown }).chains)
) {
return [];
}
const out: NamedChain[] = [];
for (const raw of (data as { chains: unknown[] }).chains) {
if (!raw || typeof raw !== "object") continue;
const { id, name, updatedAt, steps } = raw as {
id?: unknown;
name?: unknown;
updatedAt?: unknown;
steps?: unknown;
};
if (typeof id !== "string" || typeof name !== "string") continue;
const parsed = parseChain({ version: CHAIN_VERSION, steps });
if (!parsed) continue;
out.push({
id,
name,
updatedAt:
typeof updatedAt === "number" && Number.isFinite(updatedAt)
? updatedAt
: 0,
steps: parsed,
});
}
return out;
}
export function serializeNamedChains(
chains: NamedChain[],
): Record<string, unknown> {
return {
version: CHAIN_VERSION,
chains: chains.map((c) => ({
id: c.id,
name: c.name,
updatedAt: c.updatedAt,
steps: serializeSteps(c.steps),
})),
};
}
function readAll(): NamedChain[] {
const raw = storage()?.getItem(STORE_KEY);
if (!raw) return [];
try {
return parseNamedChains(JSON.parse(raw));
} catch {
return [];
}
}
function writeAll(chains: NamedChain[]): void {
storage()?.setItem(STORE_KEY, JSON.stringify(serializeNamedChains(chains)));
}
/** Newest first. */
export function listChains(): NamedChain[] {
return readAll().sort((a, b) => b.updatedAt - a.updatedAt);
}
export function getChain(id: string): NamedChain | undefined {
return readAll().find((c) => c.id === id);
}
export function createNamedChain(name: string, steps: ChainStep[]): NamedChain {
const chain: NamedChain = {
id: crypto.randomUUID(),
name: name.trim() || autoChainName(),
updatedAt: Date.now(),
steps,
};
writeAll([...readAll(), chain]);
return chain;
}
export function updateChainSteps(id: string, steps: ChainStep[]): void {
writeAll(
readAll().map((c) =>
c.id === id ? { ...c, steps, updatedAt: Date.now() } : c,
),
);
}
export function renameChain(id: string, name: string): void {
const trimmed = name.trim();
if (!trimmed) return;
writeAll(readAll().map((c) => (c.id === id ? { ...c, name: trimmed } : c)));
}
export function deleteChain(id: string): void {
writeAll(readAll().filter((c) => c.id !== id));
}
/** State that cannot persist across the tool -> pipeline route swap. */
export interface ChainHandoff {
source: PixelImage | null;
textSource: string;
format: OutputMime;
limitKb: number | undefined;
}
const handoffs = new Map<string, ChainHandoff>();
export function stashHandoff(id: string, handoff: ChainHandoff): void {
handoffs.set(id, handoff);
}
export function takeHandoff(id: string): ChainHandoff | undefined {
const handoff = handoffs.get(id);
handoffs.delete(id);
return handoff;
}
+76
View File
@@ -0,0 +1,76 @@
import { describe, expect, it } from "vitest";
import {
autoChainName,
parseNamedChains,
serializeNamedChains,
} from "./chains.svelte";
import {
CHAIN_VERSION,
createChain,
isStructuralDefault,
} from "./pipeline.svelte";
import { getPageBySlug } from "./registry";
const cropPage = getPageBySlug("crop-png")!;
describe("named chains: parse/serialize round-trip", () => {
const chain = {
id: "c1",
name: "My chain",
updatedAt: 123,
steps: createChain(cropPage),
};
it("round-trips a valid chain", () => {
const data = serializeNamedChains([chain]);
const parsed = parseNamedChains(JSON.parse(JSON.stringify(data)));
expect(parsed).toHaveLength(1);
expect(parsed[0]).toMatchObject({ id: "c1", name: "My chain" });
expect(parsed[0].steps[0].id).toBe("crop");
});
it("drops a broken entry, keeps the rest", () => {
const good = JSON.parse(JSON.stringify(serializeNamedChains([chain]))) as {
chains: unknown[];
};
const data = {
version: CHAIN_VERSION,
chains: [
good.chains[0],
{ id: "bad", name: "Broken", steps: [{ id: "nope" }] },
{ nope: true },
],
};
const parsed = parseNamedChains(data);
expect(parsed.map((c) => c.id)).toEqual(["c1"]);
});
it.each([
["junk", "just a string"],
["no version", { chains: [] }],
["other version", { version: 99, chains: [] }],
])("invalid store -> empty list (%s)", (_name, data) => {
expect(parseNamedChains(data)).toEqual([]);
});
});
describe("isStructuralDefault", () => {
it("true for the untouched page chain", () => {
expect(isStructuralDefault(cropPage, createChain(cropPage))).toBe(true);
});
it("false when a step is added or the first tool is swapped", () => {
const grown = [...createChain(cropPage), ...createChain(cropPage)];
expect(isStructuralDefault(cropPage, grown)).toBe(false);
const swapped = createChain(cropPage);
swapped[0] = { ...swapped[0], id: "resize" };
expect(isStructuralDefault(cropPage, swapped)).toBe(false);
});
});
describe("autoChainName", () => {
it("embeds the formatted stamp", () => {
const name = autoChainName(new Date(2026, 9, 8, 14, 35));
expect(name).toContain("2026-10-08 14:35");
});
});
+1 -1
View File
@@ -4,7 +4,7 @@
interface Props {
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);
format = lastTool.output?.mime ?? "image/png";
source = null;
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,19 +590,34 @@
<div class="title-block">
<h1>
{pageTitle(page)}
<button
type="button"
class="tool-fav"
class:active={isFav(page.slug)}
aria-label={isFav(page.slug) ? t("ui.removeFav") : t("ui.addFav")}
aria-pressed={isFav(page.slug)}
onclick={() => toggleFav(page.slug)}
>
<Star size={18} fill={isFav(page.slug) ? "currentColor" : "none"} />
</button>
{#if !chainId}
<button
type="button"
class="tool-fav"
class:active={isFav(page.slug)}
aria-label={isFav(page.slug) ? t("ui.removeFav") : t("ui.addFav")}
aria-pressed={isFav(page.slug)}
onclick={() => toggleFav(page.slug)}
>
<Star
size={18}
fill={isFav(page.slug) ? "currentColor" : "none"}
/>
</button>
{/if}
</h1>
<p class="lede">{pageDescription(page)}</p>
{#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);
+10 -10
View File
@@ -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);
+18 -18
View File
@@ -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/);
});
});
+11 -11
View File
@@ -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 -10
View File
@@ -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 -3
View File
@@ -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],
+10 -10
View File
@@ -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],
+5 -5
View File
@@ -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,
+28 -28
View File
@@ -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]);
});
+47 -8
View File
@@ -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
});
});
+40 -4
View File
@@ -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;
}
+7 -7
View File
@@ -5,7 +5,7 @@ import { makeImage } from "./test-helpers";
const SHARPEN_KERNEL = [0, -1, 0, -1, 5, -1, 0, -1, 0];
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],
+5 -5
View File
@@ -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");
});
});
+2 -2
View File
@@ -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) => {
+5 -5
View File
@@ -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(
+19 -3
View File
@@ -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);
});
});
+27
View File
@@ -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;
}
+3 -3
View File
@@ -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;
+18 -18
View File
@@ -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
});
});
+8 -8
View File
@@ -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);
+3 -3
View File
@@ -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,
+7 -7
View File
@@ -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,
+26 -26
View File
@@ -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);
+12 -13
View File
@@ -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,
+8 -8
View File
@@ -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
View File
@@ -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");
+31 -7
View File
@@ -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]);
});
});
+31 -7
View File
@@ -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,
+34 -12
View File
@@ -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);
});
});
+17
View File
@@ -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,
+21 -21
View File
@@ -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;
+2 -2
View File
@@ -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,
+13 -13
View File
@@ -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()]);
+9 -8
View File
@@ -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 -8
View File
@@ -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],
+5 -5
View File
@@ -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,
+50 -21
View File
@@ -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
View File
@@ -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;
}
+7 -7
View File
@@ -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();
});
});
+14 -14
View File
@@ -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);
+7 -8
View File
@@ -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