chore: clear lint rules warns: comments, tests

This commit is contained in:
2026-10-08 12:55:34 +05:00
parent 0e8c2478a1
commit 7361a82c18
93 changed files with 899 additions and 940 deletions
-11
View File
@@ -208,17 +208,6 @@
`tool-flows.spec.ts`, `schema-tool-strings.test.ts`)
- [ ] можно ли в тестах получать i18n ключ вместо текста? можно ли написать lint
правило такое (запретить toContain без i18n)
- [ ] чистка под новые конвенции комментариев и тестов: правила
`conventions/ascii-only` (тесты — ASCII), `conventions/comments-english`
(комментарии — английский), `conventions/comment-format` (≤5 строк на
блок, ≤15% файла) уже работают в warn-режиме, но в `lint:all` отключены
через override в `web/scripts/lint-all.mjs` (baseline ≠ 0). Критерий
закрытия: ноль срабатываний по всем трём правилам, overrides удалены,
правила переведены с warn на error
- [ ] убрать в тестах все нестандартные символы `→`, длинные тире, русский язык
и т.д. — часть чистки под `conventions/ascii-only` (см. выше)
- [ ] комментарии на английском — часть чистки под
`conventions/comments-english` (см. выше)
## SEO/GEO
+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);
+7 -7
View File
@@ -151,13 +151,13 @@ report-only (`warn`), baseline — 0 срабатываний.
кода, а не к сжатию комментария.
Scope правил комментариев — не только `src/`: сами плагины, `scripts/` и
`*.config.*` живут по тем же конвенциям (dogfooding, их baseline вычищен до
нуля). Фикстуры и тесты правил (`__fixtures__/`, `__tests__/`) — намеренные
входные кейсы и из scope исключены. В `src/` baseline ненулевой, поэтому все три
правила введены warn-only и в `lint:all` отключены через override в
`scripts/lint-all.mjs`, пока не сделана чистка (`docs/backlog.md`, раздел «Тесты
и lint»). Тесты: `__tests__/ascii-only.test.ts`,
`__tests__/comments-english.test.ts`, `__tests__/comment-format.test.ts`.
`*.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`.
## Токены-префиксы (целевой словарь дизайна)
+9 -8
View File
@@ -196,20 +196,22 @@ export default tseslint.config(
},
},
// Tests are written in ASCII English: typography and non-English prose in
// describe/it hurt grep-ability and hide hardcoded localized strings.
// Warn-only until the cleanup lands; intentional glyphs go to allowChars.
// 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: {
"conventions/ascii-only": "warn",
// 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).
// Warn-only: excess is a cue to refactor code, not squeeze comments.
// Excess is a cue to refactor code, not squeeze comments.
{
files: [
"**/src/**/*.ts",
@@ -226,11 +228,10 @@ export default tseslint.config(
conventions: conventionsPlugin,
},
rules: {
"conventions/comments-english": "warn",
"conventions/comments-english": "error",
"conventions/comment-format": [
"warn",
// minLines: density only checks files with 100+ non-blank lines;
// in small files any header gives a false excess.
"error",
// minLines: density skips small files (any header would trip it).
{ maxBlockLines: 5, maxRatio: 0.15, minLines: 100 },
],
},
+1 -17
View File
@@ -20,26 +20,10 @@ const bins = {
checkTokens: fileURLToPath(new URL("check-tokens.mjs", import.meta.url)),
};
// Warn-only rules with a non-zero baseline stay out of the gate until the
// cleanup lands (docs/backlog.md, tests-and-lint section): they nag in
// `pnpm lint` and editors, but --max-warnings=0 would make the gate red.
// After the cleanup the overrides are removed and the rules become errors.
const pendingCleanupOverrides = [
"conventions/ascii-only",
"conventions/comments-english",
"conventions/comment-format",
].flatMap((rule) => ["--rule", `${rule}: off`]);
const steps = [
{
name: "ESLint (svelte) + design-tokens rules",
args: [
bins.eslint,
...colorFlag,
"--max-warnings=0",
...pendingCleanupOverrides,
".",
],
args: [bins.eslint, ...colorFlag, "--max-warnings=0", "."],
},
{
name: "Stylelint (css) — design-token style",
+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[];
+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>
@@ -39,8 +39,8 @@
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);
}
@@ -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,
@@ -50,7 +50,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}`
@@ -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;
@@ -76,10 +76,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,7 +96,7 @@
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>>({});
let dragFrom = $state<number | null>(null);
@@ -105,7 +105,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 +117,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(
@@ -196,8 +196,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 +213,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 +261,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);
@@ -433,7 +433,7 @@
init();
});
// Цепочка персистится по slug страницы; сохраняем после инициализации.
// Chain persists per page slug; save only after initialization.
$effect(() => {
if (!initedFor) return;
void steps;
@@ -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]);
});
+7 -7
View File
@@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest";
import { COMPRESSION_LEVELS, findMaxColorsWithin } 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 +10,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++;
+4 -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,
+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(
+2 -2
View File
@@ -3,12 +3,12 @@ import { vignette } 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];
+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();
});
+2 -2
View File
@@ -10,7 +10,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 +18,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 },
{
+7 -7
View File
@@ -14,26 +14,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 +43,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,13 +62,13 @@ 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);
+7 -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,7 @@ export function extractByColor(
}
/**
* Считает частоты цветов и возвращает предикат «встречается не чаще limit раз».
* Counts color frequencies and returns a predicate "occurs at most limit times".
*/
export function rarityPredicate(
img: PixelImage,
+12 -12
View File
@@ -34,7 +34,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 +50,7 @@ describe("dilateMask", () => {
]);
});
it("r=2 даёт ромб радиуса 2", () => {
it("r=2 yields diamond of radius 2", () => {
const out = dilateMask(
maskFrom([".....", ".....", "..#..", ".....", "....."]),
5,
@@ -68,12 +68,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 +84,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 +99,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 +112,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 +132,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 +147,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 +169,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++) {
+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,
+20 -20
View File
@@ -2,8 +2,8 @@ import { describe, expect, it } from "vitest";
import { boxTest, circleTest, renderShape, 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 +11,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 +60,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 +70,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),
+9 -11
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,9 @@ export function wavyTest(
}
/**
* Вырезает фигуру из изображения: внутри фигуры сохраняются исходные пиксели
* (с их альфой), снаружи альфа обнуляется. Смещение задаётся в долях свободного
* места по каждой оси: ±0.5 прижимает фигуру к краю независимо от её размера,
* поэтому фигура никогда не уезжает за холст. Свободное место считается от
* extent (полуразмера фигуры); без extent смещение считается от точки.
* Cuts a shape out of the image: source pixels kept inside, alpha zeroed
* outside. 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).
*/
export function renderShape(
img: PixelImage,
+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,
+7 -7
View File
@@ -16,24 +16,24 @@ const img = makeImage(2, 1, [
]);
describe("bytes", () => {
it("round-trip rows → image → rows", () => {
it("round-trip rows -> image -> rows", () => {
const rows = imageToByteRows(img);
expect(rows).toBe("255 0 0 255 0 255 0 128");
const back = bytesToImage(rows, 2);
expect([...back.data]).toEqual([...img.data]);
});
it("некратное четырём число байт — ошибка", () => {
it("byte count not multiple of four - error", () => {
expect(() => bytesToImage("1 2 3", 1)).toThrow(/errors\.bytesCount/);
});
it("значение вне 0..255 — ошибка диапазона", () => {
it("value outside 0..255 - range error", () => {
expect(() => bytesToImage("10 20 30 256", 1)).toThrow(/errors\.byteRange/);
});
});
describe("rgb values", () => {
it("round-trip rgba-строк", () => {
it("round-trip rgba rows", () => {
const rows = imageToRgbValues(img);
expect(rows.split("\n")[0]).toBe(
"rgba(255, 0, 0, 255) rgba(0, 255, 0, 128)",
@@ -44,21 +44,21 @@ describe("rgb values", () => {
});
describe("png signature / data-uri", () => {
it("looksLikePng: настоящая сигнатура и обрывок", () => {
it("looksLikePng: real signature and truncated one", () => {
const good = new Uint8Array([137, 80, 78, 71, 13, 10, 26, 10, 1]);
const bad = new Uint8Array([137, 80, 78]);
expect(looksLikePng(good)).toBe(true);
expect(looksLikePng(bad)).toBe(false);
});
it("stripDataUri снимает префикс и оставляет чистый base64", () => {
it("stripDataUri strips prefix, keeps pure base64", () => {
expect(stripDataUri("data:image/png;base64,iVBORw==")).toBe("iVBORw==");
expect(stripDataUri(" iVBORw==")).toBe("iVBORw==");
});
});
describe("base64ToBytes", () => {
it("декодирует известную строку", () => {
it("decodes known string", () => {
const bytes = base64ToBytes("AAECAwQ=");
expect([...bytes]).toEqual([0, 1, 2, 3, 4]);
});
+3 -3
View File
@@ -4,7 +4,7 @@ import { createPixelImage } from "./types";
const PNG_SIGNATURE = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
/** Строки вида «r g b a» по одной строке на ряд пикселей. */
/** Rows of "r g b a" values, one text line per pixel row. */
export function imageToByteRows(img: PixelImage): string {
const rows: string[] = [];
for (let y = 0; y < img.height; y++) {
@@ -41,7 +41,7 @@ export function bytesToImage(text: string, width: number): PixelImage {
return out;
}
/** Строки вида «rgba(r, g, b, a)», по одной на пиксель, ряды через перевод строки. */
/** Rows of "rgba(r, g, b, a)" values, one per pixel, rows separated by newlines. */
export function imageToRgbValues(img: PixelImage): string {
const rows: string[] = [];
for (let y = 0; y < img.height; y++) {
@@ -79,7 +79,7 @@ export function rgbValuesToImage(text: string, width: number): PixelImage {
return out;
}
/** Снимает префикс data-uri (data:image/...;base64,) при наличии. */
/** Strips the data-uri prefix (data:image/...;base64,) if present. */
export function stripDataUri(text: string): string {
const m = /^\s*data:[^,\s]*base64,/i.exec(text);
return m ? text.slice(m[0].length) : text.trim();
+3 -3
View File
@@ -17,7 +17,7 @@ export function initFavorites(): void {
for (const s of list) if (typeof s === "string") slugs.add(s);
}
} catch {
// мусор в localStorage — работаем с пустым набором
// garbage in localStorage: fall back to an empty set
}
}
@@ -34,12 +34,12 @@ export function toggleFav(slug: string): void {
storage()?.setItem(KEY, JSON.stringify([...slugs]));
}
/** Избранные страницы из списка (порядок сохраняется). */
/** Favorite pages from the list (order preserved). */
export function favPages<T extends { slug: string }>(pages: readonly T[]): T[] {
return pages.filter((p) => slugs.has(p.slug));
}
/** Те же страницы, но избранные первыми (бонус в выдаче поиска). */
/** Same pages, favorites first (a boost in search results). */
export function favFirst<T extends { slug: string }>(pages: readonly T[]): T[] {
return [...pages].sort(
(a, b) => Number(slugs.has(b.slug)) - Number(slugs.has(a.slug)),
+3 -3
View File
@@ -8,7 +8,7 @@ import {
} from "./favorites.svelte";
describe("favorites", () => {
it("toggle добавляет и убирает, isFav отражает состояние", () => {
it("toggle adds and removes, isFav reflects state", () => {
expect(isFav("flip-png")).toBe(false);
toggleFav("flip-png");
expect(isFav("flip-png")).toBe(true);
@@ -16,7 +16,7 @@ describe("favorites", () => {
expect(isFav("flip-png")).toBe(false);
});
it("favPages фильтрует, favFirst поднимает избранное", () => {
it("favPages filters, favFirst lifts favorites", () => {
toggleFav("resize-png");
const pages = [{ slug: "crop-png" }, { slug: "resize-png" }];
expect(favPages(pages)).toEqual([{ slug: "resize-png" }]);
@@ -27,7 +27,7 @@ describe("favorites", () => {
toggleFav("resize-png");
});
it("initFavorites без localStorage и с мусором не падает", () => {
it("initFavorites without localStorage and with junk does not throw", () => {
expect(() => initFavorites()).not.toThrow();
});
});
+10 -10
View File
@@ -16,19 +16,19 @@ export function isLocale(value: unknown): value is Locale {
);
}
/** Тексты страницы (`pages.<slug>`): адрес, имя файла и иконка — по slug. */
/** Page strings (`pages.<slug>`): title and description keyed by slug. */
export type PageStrings = {
title?: string;
description?: string;
};
/** Тексты инструмента (`tools.<id>`): подписи полей, verdict-ключи. */
/** Tool strings (`tools.<id>`): field labels, verdict keys. */
export type ToolStrings = {
/** Подписи параметров по их id. */
/** Param labels by param id. */
params?: Record<string, string>;
/** Подписи опций select: paramId -> value -> label. */
/** Select option labels: paramId -> value -> label. */
options?: Record<string, Record<string, string>>;
/** Тексты-результаты текстовых инструментов (analyze): ключ -> строка. */
/** Result strings of text tools (analyze): key -> string. */
results?: Record<string, string>;
};
@@ -59,15 +59,15 @@ export type Dict = {
search: Record<string, string>;
ui: Record<string, string>;
errors: Record<string, string>;
/** Заголовки и описания страниц; в базовой локали их нет — берётся EN из реестра. */
/** Page titles/descriptions; absent in the base locale, EN from the registry is used. */
pages?: Record<string, PageStrings>;
tools: Record<string, ToolStrings>;
/** Действия панели результата/генерации (Generate, Open image…). */
/** Result/generation panel actions (Generate, Open image...). */
actions?: Record<string, string>;
/** Поля ввода текста нового UI (placeholder, Try sample…). */
/** Text input fields of the new UI (placeholder, Try sample...). */
textSource?: Record<string, string>;
/** Подписи полей по ключу (решение C: label-ключ на поле). */
/** Field labels by key (decision C: label key on the field). */
fields?: Record<string, string>;
/** Заголовки групп полей по ключу (решение A). */
/** Field group titles by key (decision A). */
groups?: Record<string, string>;
};
+2 -2
View File
@@ -352,8 +352,8 @@ export const en: Dict = {
tile: "Tile",
watermark: "Watermark",
},
// Заголовки и описания страниц в базовой локали не дублируются: их берёт
// EN-строка из реестра страниц (`web/src/lib/registry/pages/`).
// Page titles/descriptions are not duplicated in the base locale: the EN
// string from the page registry (`web/src/lib/registry/pages/`) is used.
pages: {},
tools: {
"file-size": {
+18 -17
View File
@@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
import { getLocale, initLocale, setLocale } from "./locale.svelte";
import { interpolate, t } from "./t";
import { en } from "./en";
import { ru } from "./ru";
type Store = Record<string, string>;
@@ -24,19 +25,19 @@ beforeEach(() => {
});
describe("t", () => {
it("возвращает строку по точечному пути активной локали", () => {
expect(t("header.workspace")).toBe("Рабочая область");
it("returns the string by dot path of the active locale", () => {
expect(t("header.workspace")).toBe(ru.header.workspace);
setLocale("en");
expect(t("header.catalog")).toBe("Catalog");
expect(t("header.catalog")).toBe(en.header.catalog);
});
it("фолбэк на английскую базу, если в активной локали нет ключа", () => {
it("falls back to the English base when the active locale lacks the key", () => {
(en.home as Record<string, string>).onlyEnKey = "Only English string";
setLocale("ru");
expect(t("home.onlyEnKey")).toBe("Only English string");
});
it("переводит ошибки с vars в активной локали", () => {
it("translates errors with vars in the active locale", () => {
setLocale("ru");
expect(t("errors.pixelCountMismatch", { count: 33, width: 32 })).toContain(
"33",
@@ -47,43 +48,43 @@ describe("t", () => {
);
});
it("неизвестный путь возвращает сам путь", () => {
it("unknown path returns the path itself", () => {
expect(t("no.such.key")).toBe("no.such.key");
});
});
describe("interpolate", () => {
it("подставляет переменные в шаблон", () => {
expect(interpolate("Шаг {n} из {total}", { n: 2, total: 5 })).toBe(
"Шаг 2 из 5",
it("substitutes variables into the template", () => {
expect(interpolate("Step {n} of {total}", { n: 2, total: 5 })).toBe(
"Step 2 of 5",
);
});
it("оставляет плейсхолдер без переменной как есть", () => {
expect(interpolate("Привет, {name}!", {})).toBe("Привет, {name}!");
it("leaves a placeholder without a variable as-is", () => {
expect(interpolate("Hello, {name}!", {})).toBe("Hello, {name}!");
});
it("без переменных возвращает строку без изменений", () => {
expect(interpolate("Просто текст")).toBe("Просто текст");
it("without variables returns the string unchanged", () => {
expect(interpolate("Plain text")).toBe("Plain text");
});
});
describe("персист локали", () => {
it("initLocale читает сохранённый выбор", () => {
describe("locale persistence", () => {
it("initLocale reads the saved choice", () => {
const { store } = stubStorage();
store["locale"] = "en";
initLocale();
expect(getLocale()).toBe("en");
});
it("initLocale игнорирует мусор в хранилище", () => {
it("initLocale ignores garbage in the storage", () => {
const { store } = stubStorage();
store["locale"] = "fr";
initLocale();
expect(getLocale()).toBe("ru");
});
it("setLocale сохраняет выбор в localStorage", () => {
it("setLocale saves the choice to localStorage", () => {
const { store } = stubStorage();
initLocale();
setLocale("en");
+2 -2
View File
@@ -20,7 +20,7 @@ export function getDict(): Dict {
return DICTS[locale];
}
/** Словарь активной локали; для отсутствующих в ней значений — фолбэк на базовую. */
/** Dictionary of the active locale; missing values fall back to the base locale. */
export function getMergedDict(): Dict {
if (locale === BASE_LOCALE) return DICTS[BASE_LOCALE];
const active = DICTS[locale];
@@ -43,7 +43,7 @@ export function setLocale(next: Locale): void {
syncLangAttr();
}
/** Читает сохранённый выбор и синхронизирует атрибут lang. Вызывается на клиенте. */
/** Reads the saved choice and syncs the lang attribute. Called on the client. */
export function initLocale(): void {
if (typeof window === "undefined") return;
const saved = storage()?.getItem(STORAGE_KEY);
+39 -21
View File
@@ -1,52 +1,70 @@
import { describe, expect, it } from "vitest";
import { normalizeForSearch, scoreDoc, type SearchDoc } from "./matching";
import { ru } from "./ru";
const YO = "\u0451";
const YE = "\u0435";
describe("normalizeForSearch", () => {
it("нижний регистр", () => {
it("lowercases the query", () => {
expect(normalizeForSearch("Rotate PNG")).toBe("rotate png");
});
it("ё заменяется на е", () => {
expect(normalizeForSearch("Ёлка и ёж")).toBe("елка и еж");
it("folds yo to ye", () => {
// dictionary word containing U+0451, from the grayscale page title
const withYo = (ru.pages?.["grayscale-png"]?.title ?? "").split(" ")[0];
expect(withYo).toContain(YO);
const withYe = withYo.replaceAll(YO, YE);
expect(normalizeForSearch(withYo)).toBe(normalizeForSearch(withYe));
});
it("диакритика снимается через NFD", () => {
expect(normalizeForSearch("café")).toBe("cafe");
expect(normalizeForSearch("Über")).toBe("uber");
it("strips diacritics via NFD", () => {
expect(normalizeForSearch("caf\u00e9")).toBe("cafe");
expect(normalizeForSearch("\u00dcber")).toBe("uber");
});
});
describe("scoreDoc", () => {
// fixture derived from the ru dictionary entry of rotate-free-png
const page = ru.pages?.["rotate-free-png"];
const doc: SearchDoc = {
id: "rotate-free-png",
titles: ["Повернуть на произвольный угол"],
descriptions: [
"Поворот на любой угол. Холст расширяется под новые габариты.",
],
titles: [page?.title ?? ""],
descriptions: [page?.description ?? ""],
};
const normTitle = normalizeForSearch(doc.titles[0]);
it("пустой запрос даёт нейтральный балл", () => {
it("empty query gives a neutral score", () => {
expect(scoreDoc(doc, "")).toBe(1);
});
it("префикс названия ценнее подстроки", () => {
const prefix = scoreDoc(doc, normalizeForSearch("повер"));
const infix = scoreDoc(doc, normalizeForSearch("верну"));
it("title prefix scores higher than an infix", () => {
const prefix = scoreDoc(doc, normTitle.slice(0, 5));
const infix = scoreDoc(doc, normTitle.slice(2, 7));
expect(prefix!).toBe(100);
expect(infix!).toBeGreaterThan(30);
});
it("подстрока названия ценнее совпадения в описании", () => {
const title = scoreDoc(doc, normalizeForSearch("угол"));
const desc = scoreDoc(doc, normalizeForSearch("габариты"));
it("title substring scores higher than a description match", () => {
// last title word vs a long description word absent from the title
const titleWord = normTitle.split(" ").at(-1)!;
const descWord = normalizeForSearch(doc.descriptions[0])
.split(/[^\p{L}\p{N}]+/u)
.filter((w) => w.length >= 5 && !normTitle.includes(w))
.at(-1)!;
const title = scoreDoc(doc, titleWord);
const desc = scoreDoc(doc, descWord);
expect(title!).toBeGreaterThan(desc!);
});
it("нет совпадения — null", () => {
expect(scoreDoc(doc, normalizeForSearch("квант"))).toBeNull();
it("no match returns null", () => {
expect(scoreDoc(doc, "zzz")).toBeNull();
});
it("запрос с опечаткой ё/е всё равно находит", () => {
expect(scoreDoc(doc, normalizeForSearch("повёрнут"))).not.toBeNull();
it("a query with a yo/ye typo still matches", () => {
// title prefix with ye replaced by yo, derived from the ru title
const typo = normTitle.slice(0, 8).replace(YE, YO);
expect(typo).toContain(YO);
expect(scoreDoc(doc, normalizeForSearch(typo))).not.toBeNull();
});
});
+3 -3
View File
@@ -1,5 +1,5 @@
/**
* Нормализация строки для поиска: нижний регистр, ё→е, снятие диакритики (NFD).
* Search string normalization: lowercase, yo->ye (Cyrillic), strip diacritics (NFD).
*/
export function normalizeForSearch(value: string): string {
return value
@@ -45,8 +45,8 @@ function bestTitleScore(titles: string[], q: string): number | null {
}
/**
* Скоринг документа против нормализованного запроса.
* Пустой запрос — нейтральный балл; отсутствие совпадения — null.
* Scores a document against a normalized query.
* Empty query: neutral score; no match: null.
*/
export function scoreDoc(
doc: SearchDoc,
+51 -48
View File
@@ -1,12 +1,12 @@
import { afterEach, describe, expect, it } from "vitest";
import { PAGES } from "../registry";
import { setLocale } from "./locale.svelte";
import {
pageDescription,
pageTitle,
searchPages,
verdictText,
} from "./schema-tool-strings";
import { en } from "./en";
import { ru } from "./ru";
import { searchPages, verdictText } from "./schema-tool-strings";
const YO = "\u0451";
const YE = "\u0435";
afterEach(() => {
setLocale("ru");
@@ -14,84 +14,87 @@ afterEach(() => {
const slugs = (pages: { slug: string }[]) => pages.map((page) => page.slug);
describe("searchPages (кросс-языковой поиск каталога)", () => {
it("пустой запрос возвращает весь каталог", () => {
describe("searchPages (cross-locale catalog search)", () => {
it("empty query returns the whole catalog", () => {
expect(searchPages(PAGES, "")).toHaveLength(PAGES.length);
expect(searchPages(PAGES, " ")).toHaveLength(PAGES.length);
});
it("находит ru-название, даже когда локаль en", () => {
it("finds the ru title while the locale is en", () => {
setLocale("en");
expect(slugs(searchPages(PAGES, "обрез"))).toContain("crop-png");
expect(slugs(searchPages(PAGES, "пустые поля"))).toContain(
// query derived from the ru title
const cropQuery = (ru.pages?.["crop-png"]?.title ?? "")
.toLowerCase()
.slice(0, 5);
expect(slugs(searchPages(PAGES, cropQuery))).toContain("crop-png");
// two middle words of the ru title (verb, X, Y, PNG)
const trimQuery = (ru.pages?.["trim-empty-space-png"]?.title ?? "")
.toLowerCase()
.split(" ")
.slice(1, 3)
.join(" ");
expect(slugs(searchPages(PAGES, trimQuery))).toContain(
"trim-empty-space-png",
);
});
it("находит en-название, даже когда локаль ru", () => {
it("finds the en title while the locale is ru", () => {
expect(slugs(searchPages(PAGES, "resize"))).toContain("resize-png");
expect(slugs(searchPages(PAGES, "round corners"))).toContain(
"round-corners-png",
);
});
it("находит по slug (тексту адреса)", () => {
it("finds by slug (address text)", () => {
expect(slugs(searchPages(PAGES, "crop png"))).toContain("crop-png");
});
it("ё и е — один запрос", () => {
const byYo = searchPages(PAGES, "чёрно");
const byYe = searchPages(PAGES, "черно");
expect(slugs(byYo)).toEqual(slugs(byYe));
it("yo and ye spellings are one query", () => {
// query with U+0451 derived from the ru grayscale title, then folded to U+0435
const qYo = (ru.pages?.["grayscale-png"]?.title ?? "")
.split(" ")[0]
.slice(0, 5);
expect(qYo).toContain(YO);
const qYe = qYo.replaceAll(YO, YE);
expect(slugs(searchPages(PAGES, qYo))).toEqual(
slugs(searchPages(PAGES, qYe)),
);
});
it("нет совпадения — пустой список", () => {
expect(searchPages(PAGES, "квантовый тостер")).toEqual([]);
it("no match returns an empty list", () => {
expect(searchPages(PAGES, "zzz qqq")).toEqual([]);
});
});
describe("verdictText (вердикт с vars)", () => {
it("интерполирует {vars} в обоих локалях", () => {
describe("verdictText (verdict with vars)", () => {
it("interpolates {vars} in both locales", () => {
setLocale("ru");
expect(verdictText("file-size", "line", { kb: "12.3" })).toBe(
"Размер PNG: 12.3 КБ",
(ru.tools["file-size"]?.results?.line ?? "").replace("{kb}", "12.3"),
);
setLocale("en");
expect(verdictText("file-size", "line", { kb: "12.3" })).toBe(
"PNG size: 12.3 KB",
(en.tools["file-size"]?.results?.line ?? "").replace("{kb}", "12.3"),
);
});
it("без vars возвращает ключ как есть при отсутствии в словаре", () => {
expect(verdictText("file-size", "нет-такого")).toBe("нет-такого");
it("returns the key as-is when it is missing from the dictionary", () => {
expect(verdictText("file-size", "no-such-key")).toBe("no-such-key");
});
it.each([
[
"verify-png",
"verifyYes",
"Да — сигнатура настоящего PNG.",
"Yes — this is a valid PNG signature.",
],
[
"is-transparent",
"transparentNo",
"Нет — все пиксели полностью непрозрачны.",
"No — all pixels are fully opaque.",
],
[
"orientation",
"orientationLandscape",
"Ландшафт — ширина больше высоты.",
"Landscape — width is greater than height.",
],
])(
"переводит verdict %s/%s в обеих локалях",
(toolId, key, ruText, enText) => {
["verify-png", "verifyYes"],
["is-transparent", "transparentNo"],
["orientation", "orientationLandscape"],
])("translates verdict %s/%s in both locales", (toolId, key) => {
const ruText = ru.tools[toolId]?.results?.[key];
const enText = en.tools[toolId]?.results?.[key];
expect(ruText, `${toolId}/${key} @ru dict`).toBeTruthy();
expect(enText, `${toolId}/${key} @en dict`).toBeTruthy();
expect(ruText).not.toBe(enText);
setLocale("ru");
expect(verdictText(toolId, key)).toBe(ruText);
setLocale("en");
expect(verdictText(toolId, key)).toBe(enText);
},
);
});
});
+4 -4
View File
@@ -33,7 +33,7 @@ export function groupLabel(title: string): string {
return t(title);
}
/** Подпись опции select: словарь `tools[id].options[fieldId][value]`, фолбэк — label из схемы. */
/** Select option label: dict `tools[id].options[fieldId][value]`, fallback is the schema label. */
export function optionLabel(
toolId: string,
fieldId: string,
@@ -82,9 +82,9 @@ export function pageSearchDoc(page: Page): SearchDoc {
}
/**
* Кросс-языковой поиск по каталогу: скор через `scoreDoc` на
* `pageSearchDoc` (заголовки/описания всех локалей). Порядок каталога
* сохраняется, элементы без совпадения отбрасываются.
* Cross-language catalog search: scores via `scoreDoc` over `pageSearchDoc`
* (titles/descriptions of all locales). Catalog order is preserved,
* non-matching entries are dropped.
*/
export function searchPages(pages: readonly Page[], query: string): Page[] {
const q = normalizeForSearch(query.trim());
+35 -30
View File
@@ -5,14 +5,16 @@ import { normalizeForSearch } from "./matching";
import { t } from "./t";
import { getPageBySlug, PAGES } from "../registry";
import { pageTitle } from "./schema-tool-strings";
import { en } from "./en";
import { ru } from "./ru";
afterEach(() => {
setLocale("ru");
vi.unstubAllGlobals();
});
describe("Смоук §6 i18n (новый UI)", () => {
it("1. html lang следует за локалью", () => {
describe("smoke: i18n (new UI)", () => {
it("1. html lang follows the locale", () => {
const doc = { documentElement: { lang: "" } };
vi.stubGlobal("document", doc);
setLocale("en");
@@ -21,66 +23,69 @@ describe("Смоук §6 i18n (новый UI)", () => {
expect(doc.documentElement.lang).toBe("ru");
});
it("2. getLocale отражает последний выбор", () => {
it("2. getLocale reflects the last choice", () => {
setLocale("en");
expect(getLocale()).toBe("en");
setLocale("ru");
expect(getLocale()).toBe("ru");
});
it("3. Ключевые секции переведены без смеси языков", () => {
const samples: Array<[string, string, string]> = [
["header.workspace", "Рабочая область", "Workspace"],
["catalog.heading", "Каталог инструментов", "Tool catalog"],
[
"home.heroTitle",
"Что делаем с изображением?",
"What do you want to do",
],
["chain.inputLegend", "Вход", "Input"],
["resultCard.nextTool", "Следующий инструмент", "Next tool"],
["download.busy", "Готовим файл", "Preparing file"],
["dropZone.pickDefault", "Перетащите изображение", "Drop an image"],
it("3. key sections are translated without mixing languages", () => {
const samples: Array<[string, string | undefined, string]> = [
["header.workspace", ru.header.workspace, "Workspace"],
["catalog.heading", ru.catalog.heading, "Tool catalog"],
["home.heroTitle", ru.home.heroTitle, "What do you want to do"],
["chain.inputLegend", ru.chain.inputLegend, "Input"],
["resultCard.nextTool", ru.resultCard.nextTool, "Next tool"],
["download.busy", ru.download.busy, "Preparing file"],
["dropZone.pickDefault", ru.dropZone.pickDefault, "Drop an image"],
];
for (const [key, ruPart, enPart] of samples) {
for (const [key, ruValue, enPart] of samples) {
setLocale("ru");
expect(t(key), key + " @ru").toContain(ruPart);
expect(t(key), key + " @ru").toBe(ruValue);
setLocale("en");
expect(t(key), key + " @en").toContain(enPart);
}
});
it("4. Заголовок инструмента переключается локалями (schema-tool-strings)", () => {
it("4. tool title switches with the locale (schema-tool-strings)", () => {
const page = getPageBySlug("crop-png") ?? PAGES[0];
setLocale("ru");
expect(pageTitle(page)).toContain("Обрезать");
expect(pageTitle(page)).toBe(ru.pages?.[page.slug]?.title);
setLocale("en");
expect(pageTitle(page)).toBe(page.title);
});
it("5. Ошибки с vars локализуются на оба языка", () => {
it("5. errors with vars localize to both languages", () => {
setLocale("ru");
expect(t("errors.badHex", { value: "#zz" })).toBe(
'Некорректный HEX-цвет: "#zz"',
(ru.errors.badHex ?? "").replace("{value}", "#zz"),
);
expect(t("errors.pageUnknown", { slug: "x" })).toBe(
(ru.errors.pageUnknown ?? "").replace("{slug}", "x"),
);
expect(t("errors.pageUnknown", { slug: "x" })).toContain("каталоге");
setLocale("en");
expect(t("errors.badHex", { value: "#zz" })).toBe(
'Invalid HEX color: "#zz"',
(en.errors.badHex ?? "").replace("{value}", "#zz"),
);
expect(t("errors.pageUnknown", { slug: "x" })).toContain("No page");
});
it("6. Ё не мешает нормализации поискового запроса", () => {
expect(normalizeForSearch("ЧЁРНО") === normalizeForSearch("ЧЕРНО")).toBe(
true,
it("6. U+0451 (yo) does not break query normalization", () => {
const YO = "\u0451";
const YE = "\u0435";
// dictionary word containing U+0451, from the ru grayscale title
const word = (ru.pages?.["grayscale-png"]?.title ?? "").split(" ")[0];
expect(word).toContain(YO);
expect(normalizeForSearch(word)).toBe(
normalizeForSearch(word.replaceAll(YO, YE)),
);
expect(normalizeForSearch("ёлка") === normalizeForSearch("елка")).toBe(
true,
expect(normalizeForSearch(word.toUpperCase())).toBe(
normalizeForSearch(word.toUpperCase().replaceAll(YO, YE)),
);
});
it("7. Теги локалей для форматирования чисел корректны", () => {
it("7. locale tags for number formatting are correct", () => {
expect(LOCALE_TAGS.ru).toBe("ru-RU");
expect(LOCALE_TAGS.en).toBe("en-US");
});
+2 -2
View File
@@ -11,8 +11,8 @@ export function interpolate(
}
/**
* Перевод по точечному пути вида 'header.workspace' или 'errors.ERR_BAD_HEX'.
* Сначала активная локаль, затем базовая; если ключа нет нигде — возвращается сам путь.
* Translation by dot path like 'header.workspace' or 'errors.ERR_BAD_HEX'.
* Active locale first, then base; if the key is nowhere, the path itself is returned.
*/
export function t(
path: string,
+17 -19
View File
@@ -31,35 +31,35 @@ function selectOptions(
return result;
}
describe("полнота словарей для нового registry", () => {
it("у каждой страницы есть перевод ru с непустыми title/description", () => {
describe("dictionary completeness for the new registry", () => {
it("every page has an ru translation with non-empty title/description", () => {
for (const page of PAGES) {
const strings = ru.pages?.[page.slug];
expect(strings, `нет перевода ru для ${page.slug}`).toBeDefined();
expect(strings, `no ru translation for ${page.slug}`).toBeDefined();
expect(strings?.title, `${page.slug}: title`).toBeTruthy();
expect(strings?.description, `${page.slug}: description`).toBeTruthy();
}
});
it("каждый label-ключ поля схемы переведён в fields обоих словарей", () => {
it("every schema field label key is translated in fields of both dictionaries", () => {
for (const tool of TOOLS) {
for (const key of toolKeys(tool).fields) {
expect(en.fields?.[key], `${tool.id}: ${key} в en`).toBeTruthy();
expect(ru.fields?.[key], `${tool.id}: ${key} в ru`).toBeTruthy();
expect(en.fields?.[key], `${tool.id}: ${key} in en`).toBeTruthy();
expect(ru.fields?.[key], `${tool.id}: ${key} in ru`).toBeTruthy();
}
}
});
it("каждый groups-ключ схемы переведён в groups обоих словарей", () => {
it("every schema groups key is translated in groups of both dictionaries", () => {
for (const tool of TOOLS) {
for (const key of toolKeys(tool).groups) {
expect(en.groups?.[key], `${tool.id}: ${key} в en`).toBeTruthy();
expect(ru.groups?.[key], `${tool.id}: ${key} в ru`).toBeTruthy();
expect(en.groups?.[key], `${tool.id}: ${key} in en`).toBeTruthy();
expect(ru.groups?.[key], `${tool.id}: ${key} in ru`).toBeTruthy();
}
}
});
it("все active select options переведены в en и ru", () => {
it("all active select options are translated in en and ru", () => {
for (const tool of TOOLS) {
for (const { fieldId, option } of selectOptions(tool)) {
if (fieldId === "component") continue;
@@ -75,33 +75,31 @@ describe("полнота словарей для нового registry", () => {
}
});
it("в секции pages нет лишних страниц", () => {
it("the pages section has no extra pages", () => {
const slugs = new Set(PAGES.map((page) => page.slug));
for (const [locale, dict] of [
["ru", ru],
["en", en],
] as const) {
for (const key of Object.keys(dict.pages ?? {})) {
expect(slugs.has(key), `лишняя страница в ${locale}: ${key}`).toBe(
true,
);
expect(slugs.has(key), `extra page in ${locale}: ${key}`).toBe(true);
}
}
});
it("в словарях нет ключей без записи в реестре", () => {
it("dictionaries have no keys without a registry entry", () => {
const known = new Set([
...TOOLS.map((tool) => tool.id),
...PAGES.map((page) => page.slug),
]);
for (const dict of [ru.tools, en.tools]) {
for (const key of Object.keys(dict)) {
expect(known.has(key), `лишний ключ в словаре: ${key}`).toBe(true);
expect(known.has(key), `extra dictionary key: ${key}`).toBe(true);
}
}
});
it("в fields и groups нет ключей, не используемых схемами", () => {
it("fields and groups have no keys unused by schemas", () => {
const usedFields = new Set<string>();
const usedGroups = new Set<string>();
for (const tool of TOOLS) {
@@ -111,10 +109,10 @@ describe("полнота словарей для нового registry", () => {
}
for (const dict of [en, ru]) {
for (const key of Object.keys(dict.fields ?? {})) {
expect(usedFields.has(key), `лишний fields-ключ: ${key}`).toBe(true);
expect(usedFields.has(key), `extra fields key: ${key}`).toBe(true);
}
for (const key of Object.keys(dict.groups ?? {})) {
expect(usedGroups.has(key), `лишний groups-ключ: ${key}`).toBe(true);
expect(usedGroups.has(key), `extra groups key: ${key}`).toBe(true);
}
}
});
+5 -5
View File
@@ -131,8 +131,8 @@ function ratio(fgHex: string, bgHex: string): number {
describe.each([
["light", lightEnv],
["dark", darkEnv],
])("контраст палитры (%s)", (_name, theme) => {
it("основной текст на фоне и панели ≥ 4.5", () => {
])("palette contrast (%s)", (_name, theme) => {
it("main text on background and panel >= 4.5", () => {
expect(
ratio(
colorOf(theme, "--color-text"),
@@ -144,7 +144,7 @@ describe.each([
).toBeGreaterThanOrEqual(4.5);
});
it("вторичный текст (muted) на фоне и панели ≥ 4.5", () => {
it("muted text on background and panel >= 4.5", () => {
expect(
ratio(
colorOf(theme, "--color-text-muted"),
@@ -159,7 +159,7 @@ describe.each([
).toBeGreaterThanOrEqual(4.5);
});
it("текст-цвет на акцентной кнопке ≥ 4.0 (AA large-text)", () => {
it("text color on accent button >= 4.0 (AA large-text)", () => {
expect(
ratio(
colorOf(theme, "--color-background"),
@@ -174,7 +174,7 @@ describe.each([
).toBeGreaterThanOrEqual(4.0);
});
it("бордер панели различим на фоне ≥ 1.15", () => {
it("panel border distinguishable on background >= 1.15", () => {
expect(
ratio(
colorOf(theme, "--color-border"),
+3 -3
View File
@@ -22,7 +22,7 @@ function storage(): Storage | null {
return typeof localStorage === "undefined" ? null : localStorage;
}
/** Инструмент можно вставить в середину цепочки: принимает и отдаёт картинку. */
/** Tool can be inserted mid-chain: it accepts and returns an image. */
export function isChainable(toolId: string): boolean {
const tool = getTool(toolId);
return (
@@ -32,7 +32,7 @@ export function isChainable(toolId: string): boolean {
);
}
/** Страницы, чьи инструменты можно добавлять шагами (для picker'а). */
/** Pages whose tools can be added as steps (for the picker). */
export function chainablePages(): Page[] {
return PAGES.filter((p) => isChainable(p.steps[0].id));
}
@@ -57,7 +57,7 @@ export function createChain(page: Page): ChainStep[] {
return steps;
}
/** Валидация сохранённой цепочки; невалидные данные → null (дефолт страницы). */
/** Validates a saved chain; invalid data -> null (page default). */
export function parseChain(data: unknown): ChainStep[] | null {
if (
!data ||
+17 -20
View File
@@ -13,15 +13,15 @@ import { getPageBySlug } from "./registry";
const cropPage = getPageBySlug("crop-png")!;
describe("pipeline: цепочка шагов", () => {
it("createChain собирает шаги из страницы с дефолтами схемы", () => {
describe("pipeline: step chain", () => {
it("createChain builds steps from page with schema defaults", () => {
const chain = createChain(cropPage);
expect(chain).toHaveLength(1);
expect(chain[0].id).toBe("crop");
expect(chain[0].params).toMatchObject({ x: 0, y: 0 });
});
it("isChainable: image→image можно, терминальные и text-вход нельзя", () => {
it("isChainable: image->image allowed, terminal and text-input not", () => {
expect(isChainable("resize")).toBe(true);
expect(isChainable("to-base64")).toBe(false); // result: text
expect(isChainable("split-into-parts")).toBe(false); // result: files
@@ -29,22 +29,22 @@ describe("pipeline: цепочка шагов", () => {
expect(isChainable("unknown-tool")).toBe(false);
});
it("chainablePages содержит только совместимые страницы", () => {
it("chainablePages holds only compatible pages", () => {
const pages = chainablePages();
expect(pages.some((p) => p.slug === "resize-png")).toBe(true);
expect(pages.some((p) => p.slug === "png-to-base64")).toBe(false);
});
it("insertStep вставляет шаг в произвольную позицию", () => {
it("insertStep inserts step at arbitrary position", () => {
const chain = createChain(cropPage);
const grown = insertStep(chain, 0, "resize");
expect(grown.map((s) => s.id)).toEqual(["resize", "crop"]);
expect(grown[0].params).toHaveProperty("keepAspect");
// исходная цепочка не мутирует
// original chain not mutated
expect(chain).toHaveLength(1);
});
it("removeStep не удаляет последний шаг", () => {
it("removeStep does not remove last step", () => {
const chain = createChain(cropPage);
expect(removeStep(chain, chain[0].key)).toHaveLength(1);
const grown = insertStep(chain, 1, "flip");
@@ -52,7 +52,7 @@ describe("pipeline: цепочка шагов", () => {
expect(shrunk.map((s) => s.id)).toEqual(["flip"]);
});
it("moveStep меняет порядок, ключи шагов сохраняются", () => {
it("moveStep reorders, step keys preserved", () => {
let chain = createChain(cropPage);
chain = insertStep(chain, 1, "flip");
chain = insertStep(chain, 2, "grayscale");
@@ -64,7 +64,7 @@ describe("pipeline: цепочка шагов", () => {
});
});
describe("pipeline: parseChain (localStorage/импорт)", () => {
describe("pipeline: parseChain (localStorage/import)", () => {
const valid = {
version: CHAIN_VERSION,
steps: [
@@ -73,13 +73,13 @@ describe("pipeline: parseChain (localStorage/импорт)", () => {
],
};
it("валидная цепочка разбирается, параметры санитизируются", () => {
it("valid chain parses, params sanitized", () => {
const chain = parseChain(valid)!;
expect(chain.map((s) => s.id)).toEqual(["crop", "resize"]);
expect(chain[0].params).toMatchObject({ x: 5, y: 0 });
});
it("parseChain сохраняет collapsed, по умолчанию шаг развёрнут", () => {
it("parseChain keeps collapsed, default expanded", () => {
const chain = parseChain({
version: CHAIN_VERSION,
steps: [
@@ -92,15 +92,12 @@ describe("pipeline: parseChain (localStorage/импорт)", () => {
});
it.each([
["нет версии", { steps: [{ id: "crop" }] }],
["другая версия", { version: 99, steps: [{ id: "crop" }] }],
["пустой список", { version: CHAIN_VERSION, steps: [] }],
[
"неизвестный инструмент",
{ version: CHAIN_VERSION, steps: [{ id: "nope" }] },
],
["мусор", "just a string"],
])("невалидные данные → null (%s)", (_name, data) => {
["no version", { steps: [{ id: "crop" }] }],
["other version", { version: 99, steps: [{ id: "crop" }] }],
["empty list", { version: CHAIN_VERSION, steps: [] }],
["unknown tool", { version: CHAIN_VERSION, steps: [{ id: "nope" }] }],
["junk", "just a string"],
])("invalid data -> null (%s)", (_name, data) => {
expect(parseChain(data)).toBeNull();
});
});
+27 -27
View File
@@ -55,8 +55,8 @@ const sourceSchema = toolSchema<SourceParams>({
const source = { width: 64, height: 48 };
describe("registry-schema: дефолты из схемы", () => {
it("собирает дефолты всех полей", () => {
describe("registry-schema: schema defaults", () => {
it("collects defaults of all fields", () => {
expect(defaultSchemaParams(frameSchema)).toEqual({
thickness: 5,
color: "#000000",
@@ -69,20 +69,20 @@ describe("registry-schema: дефолты из схемы", () => {
describe("registry-schema: withAspectLock", () => {
const aspect = 64 / 48;
it("ведущая ширина — пересчитывается высота", () => {
it("leading width - height is recomputed", () => {
expect(withAspectLock({ width: 32, height: 48 }, "width", aspect)).toEqual({
width: 32,
height: 24,
});
});
it("ведущая высота — пересчитывается ширина", () => {
it("leading height - width is recomputed", () => {
expect(withAspectLock({ width: 64, height: 96 }, "height", aspect)).toEqual(
{ width: 128, height: 96 },
);
});
it("результат не опускается ниже 1", () => {
it("result never goes below 1", () => {
expect(withAspectLock({ width: 1, height: 48 }, "width", aspect)).toEqual({
width: 1,
height: 1,
@@ -106,12 +106,12 @@ describe("registry-schema: maxFromSource", () => {
});
const img = { width: 832, height: 1216 };
it("number клампится к размеру исходника, а не к статическому max", () => {
it("number clamps to the source size, not the static max", () => {
const s = sanitizeSchemaParams(cropLike, { x: 5000 }, { source: img });
expect(s.x).toBe(832);
});
it("dimension клампится по осям исходника", () => {
it("dimension clamps to the source axes", () => {
const s = sanitizeSchemaParams(
cropLike,
{ size: { width: 5000, height: 5000 } },
@@ -120,7 +120,7 @@ describe("registry-schema: maxFromSource", () => {
expect(s.size).toEqual({ width: 832, height: 1216 });
});
it("без исходника — статический max", () => {
it("without a source - static max", () => {
const s = sanitizeSchemaParams(cropLike, {
x: 5000,
size: { width: 5000, height: 5000 },
@@ -129,7 +129,7 @@ describe("registry-schema: maxFromSource", () => {
expect(s.size).toEqual({ width: 5000, height: 5000 });
});
it("maxMinus: потолок = исходник − константа (offset до size−1)", () => {
it("maxMinus: cap = source minus constant (offset up to size-1)", () => {
const offsetLike = toolSchema<{ x: number }>({
x: field.number({
min: 0,
@@ -147,7 +147,7 @@ describe("registry-schema: maxFromSource", () => {
expect(s.x).toBe(831);
});
it("effectiveMax: без maxFromSource и без исходника — статический max", () => {
it("effectiveMax: no maxFromSource and no source - static max", () => {
const plain = toolSchema<{ a: number; b: number }>({
a: field.number({ min: 0, max: 100, default: 0 }),
b: field.number({
@@ -167,27 +167,27 @@ describe("registry-schema: maxFromSource", () => {
expect(noSource.b).toBe(5000);
});
it("clampSourceAwareMaxes: без исходника и без изменений — те же params", () => {
it("clampSourceAwareMaxes: no source and no changes - same params", () => {
const noSource = clampSourceAwareMaxes(cropLike, { x: 5000 });
expect(noSource).toEqual({ x: 5000 });
const untouched = clampSourceAwareMaxes(
cropLike,
{ x: "мусор", size: { width: 100, height: 100 } },
{ x: "garbage", size: { width: 100, height: 100 } },
img,
);
expect(untouched).toEqual({
x: "мусор",
x: "garbage",
size: { width: 100, height: 100 },
});
});
it("clampSourceAwareMaxes: dimension с мусором пропускается", () => {
it("clampSourceAwareMaxes: dimension with garbage is passed through", () => {
const out = clampSourceAwareMaxes(
cropLike,
{ size: "не-объект" as unknown as Dimension },
{ size: "not-an-object" as unknown as Dimension },
img,
);
expect(out).toEqual({ size: "не-объект" });
expect(out).toEqual({ size: "not-an-object" });
});
});
@@ -216,7 +216,7 @@ describe("registry-schema: source-aware dimension defaults", () => {
});
});
it("touched-поля не перезаписываются source-дефолтами", () => {
it("touched fields are not overwritten by source defaults", () => {
const manual = { size: { width: 5, height: 6 } };
expect(
applySourceDefaults(sourceSchema, manual, { source }, new Set(["size"])),
@@ -246,14 +246,14 @@ describe("registry-schema: source-aware dimension defaults", () => {
});
});
describe("registry-schema: раскладка (schema.layout)", () => {
it("toolSchema сохраняет группы полей", () => {
describe("registry-schema: layout (schema.layout)", () => {
it("toolSchema keeps field groups", () => {
expect(frameSchema.layout).toEqual({
groups: [{ title: "Frame", fields: ["thickness", "color"] }],
});
});
it("обе схемы без layout не добавляют layout", () => {
it("schemas without layout do not add layout", () => {
const plain = toolSchema<FrameParams>({
thickness: field.slider({ min: 1, max: 500, default: 5 }),
color: field.color({ default: "#000000" }),
@@ -269,7 +269,7 @@ describe("registry-schema: раскладка (schema.layout)", () => {
expect(plain.layout).toBeUndefined();
});
it("разрешает named-группы и хвост без layout", () => {
it("resolves named groups and the ungrouped tail", () => {
expect(resolveLayoutGroups(frameSchema)).toEqual([
{
key: "Frame-0",
@@ -281,7 +281,7 @@ describe("registry-schema: раскладка (schema.layout)", () => {
]);
});
it("убирает неизвестные и повторные поля, нормализует cols", () => {
it("drops unknown and duplicate fields, normalizes cols", () => {
const schema = toolSchema<FrameParams>(
{
thickness: field.slider({ min: 1, max: 500, default: 5 }),
@@ -322,7 +322,7 @@ describe("registry-schema: раскладка (schema.layout)", () => {
]);
});
it("складывает все поля без layout в default-группу", () => {
it("puts all fields without layout into the default group", () => {
const plain = toolSchema<FrameParams>({
thickness: field.slider({ min: 1, max: 500, default: 5 }),
color: field.color({ default: "#000000" }),
@@ -346,8 +346,8 @@ describe("registry-schema: раскладка (schema.layout)", () => {
});
});
describe("registry-schema: санитайз", () => {
it("пропускает валидные значения", () => {
describe("registry-schema: sanitize", () => {
it("passes valid values through", () => {
const out = sanitizeSchemaParams(frameSchema, {
thickness: 40,
color: "#ff0000",
@@ -362,7 +362,7 @@ describe("registry-schema: санитайз", () => {
});
});
it("заменяет мусор дефолтами", () => {
it("replaces garbage with defaults", () => {
const out = sanitizeSchemaParams(frameSchema, {
thickness: "abc",
color: "not-a-color",
@@ -378,7 +378,7 @@ describe("registry-schema: санитайз", () => {
});
});
it("clamp-ит числовые поля к min/max", () => {
it("clamps numeric fields to min/max", () => {
const out = sanitizeSchemaParams(frameSchema, {
thickness: 9999,
color: "#000000",
+52 -56
View File
@@ -1,22 +1,19 @@
// Типизированная схема параметров для нового UI.
//
// Связка типов на этапе компиляции (защита от рассинхрона):
// - `field.x<T>()` возвращает `Field<T>` — phantom `__fieldT` связывает
// runtime-спецификацию поля с ожидаемым типом значения (`number`|`string`|`boolean`).
// - `toolSchema<P>(fields)` требует, чтобы ключи `fields` в точности совпадали
// с полями `P`, а `Field<P[K]>` тип-проверялся на соответствие `P[K]`.
// Ошибки компилятора ловят расхождения между интерфейсом Params и схемой.
// Typed parameter schema for the UI.
// Compile-time type binding (drift protection): `field.x<T>()` returns
// `Field<T>` whose phantom `__fieldT` ties the runtime spec to the expected
// value type; `toolSchema<P>(fields)` requires `fields` keys to match `P`
// exactly. Compiler errors catch Params/schema mismatches.
import type { TextFont } from "./core/domText";
import type { Position9 } from "./core/textdraw";
/**
* Общее необязательное поле всех спеков: ключ подписи поля в словаре.
* Рендерится через `fieldLabel` (решение C) — без ключа используется `labelOf(id)`.
* Optional field of every spec: the field's label key in the dictionary.
* Rendered via `fieldLabel` (decision C); without a key, `labelOf(id)` is used.
*/
export interface FieldSpecBase {
label?: string;
/** Поле видно только когда другое поле равно значению (режимы инструмента). */
/** Field visible only when another field equals a value (tool modes). */
visibleWhen?: { field: string; equals: string };
}
@@ -26,7 +23,7 @@ export interface NumberSpec extends FieldSpecBase {
min?: number;
max?: number;
step?: number;
/** Потолок из размеров исходника (ось); статический max — фолбэк. */
/** Cap from source dimensions (axis); static max is the fallback. */
maxFromSource?: "width" | "height";
maxMinus?: number;
}
@@ -50,7 +47,7 @@ export interface SelectSpec<V extends string = string> extends FieldSpecBase {
options: { value: V; label: string }[];
}
/** То же, что select, но рендерится кнопками-сегментами (выбор в 1 клик). */
/** Same as select but rendered as segment buttons (1-click choice). */
export interface SegmentedSpec<
V extends string = string,
> extends FieldSpecBase {
@@ -70,28 +67,28 @@ export interface CheckboxSpec extends FieldSpecBase {
default: boolean;
}
/** Составное поле «размеры»: width + height как один объект. */
/** Composite "dimensions" field: width + height as one object. */
export interface Dimension {
width: number;
height: number;
}
/** Единый список тайловых пресетов (квадраты) для dimension-полей. */
/** Single list of tile presets (squares) for dimension fields. */
export const SIZE_PRESETS = [16, 32, 64, 128, 256, 512] as const;
export interface DimensionSpec extends FieldSpecBase {
kind: "dimension";
/** Общий диапазон для обоих измерений. */
/** Shared range for both dimensions. */
min: number;
max: number;
width: number;
height: number;
defaultFromSource?: boolean;
/** Потолок каждой оси из соответствующего размера исходника. */
/** Per-axis cap from the matching source dimension. */
maxFromSource?: boolean;
/** Id checkbox-поля «сохранять пропорции». */
/** Id of the "keep aspect ratio" checkbox field. */
lockAspectWith?: string;
/** Показывать чипы быстрого выбора из SIZE_PRESETS. */
/** Show quick-pick chips from SIZE_PRESETS. */
presets?: boolean;
}
@@ -99,7 +96,7 @@ export interface SchemaContext {
source?: Dimension;
}
/** Пара цветов «от → к» (градиенты, сведение к двум цветам и т.п.). */
/** Color pair "from -> to" (gradients, two-color mapping, etc.). */
export interface ColorPair {
from: string;
to: string;
@@ -111,7 +108,7 @@ export interface ColorPairSpec extends FieldSpecBase {
to: string;
}
/** Список цветов (палитра): массив hex-строк. */
/** Color list (palette): array of hex strings. */
export type ColorList = string[];
export interface ColorListSpec extends FieldSpecBase {
@@ -119,7 +116,7 @@ export interface ColorListSpec extends FieldSpecBase {
default: string[];
}
/** Смещение фигуры/объекта по центру: x + y в процентах. */
/** Shape/object offset from center: x + y in percent. */
export interface Offset {
x: number;
y: number;
@@ -127,7 +124,7 @@ export interface Offset {
export interface OffsetSpec extends FieldSpecBase {
kind: "offset";
/** Общий диапазон для обеих осей (в процентах). */
/** Shared range for both axes (in percent). */
min: number;
max: number;
x: number;
@@ -135,8 +132,8 @@ export interface OffsetSpec extends FieldSpecBase {
}
/**
* 9-позиционная сетка (3×3): top/middle/bottom × left/center/right.
* Значение совпадает со строковым типом `Position9` из core/textdraw.
* 9-position grid (3x3): top/middle/bottom x left/center/right.
* Values match the `Position9` string type from core/textdraw.
*/
export const POSITION9_VALUES = [
"top-left",
@@ -156,8 +153,8 @@ export interface Position9Spec extends FieldSpecBase {
}
/**
* Стиль текстовой надписи: шрифт, размер, жирность и цвет как один объект
* (переиспользуется text-to-png, add-text, date-stamp).
* Text caption style: font, size, bold, and color as one object
* (reused by text-to-png, add-text, date-stamp).
*/
export interface FontStyle {
font: TextFont;
@@ -168,7 +165,7 @@ export interface FontStyle {
export interface FontStyleSpec extends FieldSpecBase {
kind: "font-style";
/** Диапазон размера шрифта. */
/** Font size range. */
min: number;
max: number;
size: number;
@@ -178,8 +175,8 @@ export interface FontStyleSpec extends FieldSpecBase {
}
/**
* Подложка-плашка под текстовой надписью: вкл/выкл, цвет и непрозрачность
* как один объект (add-text, date-stamp).
* Backing plate under a text caption: on/off, color, and opacity
* as one object (add-text, date-stamp).
*/
export interface Plate {
enabled: boolean;
@@ -195,8 +192,8 @@ export interface PlateSpec extends FieldSpecBase {
}
/**
* Цветовой градиент: пара цветов + угол направления. 0° — слева направо,
* 90° — сверху вниз (прирост по часовой в пиксельных осях, ось Y вниз).
* Color gradient: color pair + direction angle. 0 deg: left to right,
* 90 deg: top to bottom (clockwise growth in pixel axes, Y axis down).
*/
export interface Gradient {
from: string;
@@ -208,15 +205,14 @@ export interface GradientSpec extends FieldSpecBase {
kind: "gradient";
from: string;
to: string;
/** Направление градиента: 0..360°, угол в градусах. */
/** Gradient direction: 0..360, angle in degrees. */
angle: number;
}
/**
* «Объект as const» kind → спека поля. Единственный источник правды для
* перечня kinds: `FieldSpecKind` = ключи map, `FieldSpec` = значение по любому
* ключу (тот же union). Добавляем новый составной тип — добавляем сюда, и
* type-checker укажет, где его не хватает (record контролов, default/sanitize).
* "as const" object: kind -> field spec. Single source of truth for the kind
* list: `FieldSpecKind` = map keys, `FieldSpec` = union of values. A new kind
* added here makes the type-checker flag where it is missing (default/sanitize).
*/
export const fieldSpecs = {
number: {} as NumberSpec,
@@ -240,31 +236,31 @@ export type FieldSpecKind = keyof typeof fieldSpecs;
export type FieldSpecOf<K extends FieldSpecKind> = (typeof fieldSpecs)[K];
export type FieldSpec = FieldSpecOf<FieldSpecKind>;
/** `Field<T>`: runtime-спека поля + phantom-тип ожидаемого значения (number|string|boolean). */
/** `Field<T>`: runtime field spec + phantom type of the expected value (number|string|boolean). */
export interface Field<T> {
spec: FieldSpec;
/** Phantom, только для типобезопасности — не задаётся и не читается в рантайме. */
/** Phantom, for type safety only; never set or read at runtime. */
__fieldT?: T;
}
export interface ToolLayoutGroup {
/** Подпись группы (пустая группа не рендерится). */
/** Group title (an empty group is not rendered). */
title?: string;
/** Количество колонок сетки внутри группы (по умолчанию 1). */
/** Grid column count inside the group (default 1). */
cols?: number;
/** Имена полей схемы, попадающих в группу. */
/** Schema field names belonging to the group. */
fields: string[];
}
export interface ToolSchemaLayout {
/** Группы полей виджета. Неупомянутые поля — в общей группе в конце. */
/** Widget field groups. Unmentioned fields go to a trailing default group. */
groups: ToolLayoutGroup[];
}
export interface ToolSchemaMeta {
/** Пер-инструмент раскладка полей (группировка/колонки). */
/** Per-tool field layout (grouping/columns). */
layout?: ToolSchemaLayout;
/** Короткая подпись инструмента для нового UI (необязательно). */
/** Short tool label for the UI (optional). */
label?: string;
}
@@ -364,8 +360,8 @@ export const field = {
};
/**
* Собирает `ToolSchema<P>` из объявленных полей.
* Компилятор проверяет: ключи `fields` === полям `P`, типы полей совместимы с `P[K]`.
* Builds a `ToolSchema<P>` from declared fields.
* The compiler checks: `fields` keys === keys of `P`, field types match `P[K]`.
*/
export function toolSchema<P>(
fields: { [K in keyof P]: Field<P[K]> },
@@ -415,7 +411,7 @@ function clamp(value: number, min?: number, max?: number): number {
return value;
}
/** Потолок поля: из размеров исходника при maxFromSource, иначе статический. */
/** Field cap: from source dimensions under maxFromSource, else static. */
export function effectiveMax(
spec: NumberSpec,
source?: Dimension,
@@ -434,7 +430,7 @@ function dimensionAxisMax(
return spec.maxFromSource && source ? source[axis] : spec.max;
}
/** Поджимает maxFromSource-поля к актуальному потолку (сменился исходник). */
/** Clamps maxFromSource fields to the current cap (source changed). */
export function clampSourceAwareMaxes<P>(
schema: ToolSchema<P>,
params: Record<string, unknown>,
@@ -478,8 +474,8 @@ export function clampSourceAwareMaxes<P>(
}
/**
* Пересчёт при включённом lockAspect: ведущая ось (последняя правка
* пользователя) сохраняется, вторая подгоняется под аспект исходника.
* Recompute under lockAspect: the leading axis (last user edit) is kept,
* the other is fitted to the source aspect.
*/
export function withAspectLock(
next: Dimension,
@@ -503,7 +499,7 @@ function resolveDimensionDefaults(
};
}
/** Дефолты из схемы — единый источник значений по умолчанию для нового UI. */
/** Defaults from the schema: the single source of default values for the UI. */
export function defaultSchemaParams<P>(
schema: ToolSchema<P>,
context?: SchemaContext,
@@ -546,9 +542,9 @@ export function defaultSchemaParams<P>(
}
/**
* Пересчитывает дефолты от нового исходника, сохраняя значения пользователя.
* Поля из touched (изменённые вручную) не перезаписываются даже если у них
* source-дефолт — иначе загрузка нового файла сносила бы настроенные размеры.
* Recomputes defaults from a new source, preserving user values. Fields in
* touched (edited manually) are not overwritten even with a source default;
* otherwise loading a new file would wipe configured sizes.
*/
export function applySourceDefaults<P>(
schema: ToolSchema<P>,
@@ -568,7 +564,7 @@ export function applySourceDefaults<P>(
return defaults;
}
/** Валидация/нормализация значений по схеме. Аналог старого `sanitizeParams`. */
/** Validation/normalization of values against the schema. Successor of `sanitizeParams`. */
export function sanitizeSchemaParams<P>(
schema: ToolSchema<P>,
values: Record<string, unknown>,
+1 -1
View File
@@ -65,7 +65,7 @@ export function getTool(id: string): Tool | undefined {
return TOOLS.find((tool) => tool.id === id);
}
/** Единственный способ достать страницу: маршрут, крошка и имя файла идут от slug. */
/** The only way to get a page: route, crumb, and file name derive from slug. */
export function getPageBySlug(slug: string): Page | undefined {
return PAGES.find((page) => page.slug === slug);
}
+69 -68
View File
@@ -13,20 +13,20 @@ function asImage(result: ToolResult): PixelImage {
return result;
}
describe("registry-new (переведённые инструменты)", () => {
it("id инструментов уникальны", () => {
describe("registry-new (migrated tools)", () => {
it("tool ids are unique", () => {
const ids = TOOLS.map((t) => t.id);
expect(new Set(ids).size).toBe(ids.length);
});
it("slug страниц уникальны", () => {
it("page slugs are unique", () => {
const slugs = PAGES.map((page) => page.slug);
expect(new Set(slugs).size).toBe(slugs.length);
});
it("id и slug пригодны для адреса и для кода", () => {
// Тот же формат, что разбирает `tools-map-doc.test.ts`: латиница в
// нижнем регистре, слова через дефис.
it("id and slug are usable in URLs and in code", () => {
// Same format that `tools-map-doc.test.ts` parses: lowercase latin,
// words joined by hyphens.
const shape = /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/;
for (const tool of TOOLS) {
expect(shape.test(tool.id), `id: ${tool.id}`).toBe(true);
@@ -36,47 +36,45 @@ describe("registry-new (переведённые инструменты)", () =>
}
});
it("каждый шаг страницы ссылается на существующий инструмент", () => {
it("every page step references an existing tool", () => {
const ids = new Set(TOOLS.map((tool) => tool.id));
for (const page of PAGES) {
expect(page.steps.length, `${page.slug}: шагов`).toBeGreaterThan(0);
expect(page.steps.length, `${page.slug}: steps`).toBeGreaterThan(0);
for (const step of page.steps) {
expect(ids.has(step.id), `${page.slug} → ${step.id}`).toBe(true);
expect(ids.has(step.id), `${page.slug} -> ${step.id}`).toBe(true);
}
}
});
it("каждый инструмент используется хотя бы одной страницей", () => {
it("every tool is used by at least one page", () => {
const used = new Set(PAGES.flatMap((page) => page.steps.map((s) => s.id)));
const orphans = TOOLS.map((tool) => tool.id).filter((id) => !used.has(id));
expect(orphans, `инструменты без страниц:\n${orphans.join("\n")}`).toEqual(
[],
);
expect(orphans, `tools without pages:\n${orphans.join("\n")}`).toEqual([]);
});
it("guard: у страницы ровно один шаг", () => {
// Модель допускает цепочку, но `SchemaToolView` исполняет первый шаг и
// форма параметров одна на страницу. Исполнение цепочек и общая форма
// из полей двух инструментов — фаза 11 `docs/roadmap.md`.
it("guard: a page has exactly one step", () => {
// The model allows a chain, but `SchemaToolView` runs the first step
// and there is one params form per page. Chain execution and a shared
// form over two tools' fields are phase 11 of `docs/roadmap.md`.
for (const page of PAGES) {
expect(page.steps.length, `${page.slug}: шагов`).toBe(1);
expect(page.steps.length, `${page.slug}: steps`).toBe(1);
}
});
it("PREVIEW_GROUPS строится без ошибок", () => {
it("PREVIEW_GROUPS builds without errors", () => {
expect(PREVIEW_GROUPS.length).toBeGreaterThan(0);
expect(PREVIEW_GROUPS.reduce((n, g) => n + g.pages.length, 0)).toBe(
PAGES.length,
);
});
it("guard: у всех инструментов задан валидный input", () => {
it("guard: every tool has a valid input", () => {
for (const tool of TOOLS) {
expect(tool.input, tool.id).toMatch(/^(image|text|none)$/);
}
});
it("guard: input=image требует источник, input=text — текст", () => {
it("guard: input=image requires a source, input=text requires text", () => {
for (const tool of TOOLS) {
const params = sanitizeSchemaParams(tool.schema, {});
if (tool.input === "image") {
@@ -91,7 +89,7 @@ describe("registry-new (переведённые инструменты)", () =>
}
});
it("guard: input=none работает без источника", async () => {
it("guard: input=none works without a source", async () => {
const tool = TOOLS.find((t) => t.id === "create-empty")!;
const img = asImage(
await tool.run({ params: sanitizeSchemaParams(tool.schema, {}) }),
@@ -100,7 +98,7 @@ describe("registry-new (переведённые инструменты)", () =>
expect(img.height).toBe(600);
});
it("дефолты схем дают валидные параметры (включая dimension)", () => {
it("schema defaults yield valid params (including dimension)", () => {
for (const tool of TOOLS) {
const defaults = defaultSchemaParams(tool.schema);
const sanitized = sanitizeSchemaParams(tool.schema, defaults);
@@ -123,7 +121,7 @@ describe("registry-new (переведённые инструменты)", () =>
}
});
it("output metadata согласован с MIME и quality-схемой", () => {
it("output metadata is consistent with MIME and the quality schema", () => {
const extensions: Record<OutputMime, string> = {
"image/png": "png",
"image/jpeg": "jpg",
@@ -156,7 +154,7 @@ describe("registry-new (переведённые инструменты)", () =>
}
});
it("source-aware dimension defaults применяются только к image tools", () => {
it("source-aware dimension defaults apply only to image tools", () => {
for (const tool of TOOLS) {
for (const field of Object.values(tool.schema.fields)) {
if (field.spec.kind === "dimension" && field.spec.defaultFromSource) {
@@ -166,7 +164,7 @@ describe("registry-new (переведённые инструменты)", () =>
}
});
it("resize и crop получают размеры текущего source", async () => {
it("resize and crop get the dimensions of the current source", async () => {
const source = solid(64, 48);
const resize = TOOLS.find((t) => t.id === "resize")!;
const resizeDefaults = defaultSchemaParams(resize.schema, { source });
@@ -195,14 +193,14 @@ describe("registry-new (переведённые инструменты)", () =>
expect([cropped.width, cropped.height]).toEqual([64, 48]);
});
it("create-empty: dimension-дефолты корректны", () => {
it("create-empty: dimension defaults are correct", () => {
const tool = TOOLS.find((t) => t.id === "create-empty")!;
const d = defaultSchemaParams(tool.schema);
expect(d.size).toEqual({ width: 800, height: 600 });
expect(d.opacity).toBe(0);
});
it("sanitize клампит dimension к min/max и чинит мусор", () => {
it("sanitize clamps dimension to min/max and fixes garbage", () => {
const tool = TOOLS.find((t) => t.id === "create-empty")!;
const s = sanitizeSchemaParams(tool.schema, {
size: { width: 999999, height: -5 },
@@ -214,7 +212,7 @@ describe("registry-new (переведённые инструменты)", () =>
expect(bad.size).toEqual({ width: 800, height: 600 });
});
it("executeGenerate для create-empty даёт картинку нужного размера", async () => {
it("executeGenerate for create-empty returns an image of the requested size", async () => {
const tool = TOOLS.find((t) => t.id === "create-empty")!;
const params = sanitizeSchemaParams(tool.schema, {
size: { width: 320, height: 200 },
@@ -265,16 +263,19 @@ describe("registry-new (переведённые инструменты)", () =>
bgOpacity: 0,
},
],
])("генератор %s даёт картинку по dimension", async (id, params) => {
])(
"generator %s returns an image of the given dimension",
async (id, params) => {
const tool = TOOLS.find((t) => t.id === id)!;
expect(tool.input).toBe("none");
const sanitized = sanitizeSchemaParams(tool.schema, params);
const img = asImage(await tool.run({ params: sanitized }));
expect(img.width).toBe(40);
expect(img.height).toBe(30);
});
},
);
it("random-noisе детерминирован по seed", async () => {
it("random-noise is deterministic by seed", async () => {
const tool = TOOLS.find((t) => t.id === "random-noise")!;
const a = asImage(
await tool.run({
@@ -295,7 +296,7 @@ describe("registry-new (переведённые инструменты)", () =>
expect(a.data).toEqual(b.data);
});
it("resize: keepAspect с одной стороной сохраняет пропорции", async () => {
it("resize: keepAspect with one side preserves proportions", async () => {
const tool = TOOLS.find((t) => t.id === "resize")!;
const img = solid(100, 50);
const out = asImage(
@@ -311,7 +312,7 @@ describe("registry-new (переведённые инструменты)", () =>
expect(out.height).toBe(100);
});
it("crop вырезает область по x/y", async () => {
it("crop cuts out an area by x/y", async () => {
const tool = TOOLS.find((t) => t.id === "crop")!;
const img = solid(100, 100);
const out = asImage(
@@ -328,7 +329,7 @@ describe("registry-new (переведённые инструменты)", () =>
expect(out.height).toBe(40);
});
it("fit-on-background центрирует картинку на канвасе", async () => {
it("fit-on-background centers the image on the canvas", async () => {
const tool = TOOLS.find((t) => t.id === "fit-on-background")!;
const img = solid(20, 10);
const out = asImage(
@@ -346,7 +347,7 @@ describe("registry-new (переведённые инструменты)", () =>
expect(out.data[3]).toBe(0);
});
it("crop: resultNote предупреждает, когда результат ужался по границам", () => {
it("crop: resultNote warns when the result was clamped to the bounds", () => {
const tool = TOOLS.find((t) => t.id === "crop")!;
expect(
tool.resultNote?.(
@@ -362,7 +363,7 @@ describe("registry-new (переведённые инструменты)", () =>
).toBeNull();
});
it("resize: resultNote при keepAspect и скорректированной стороне", () => {
it("resize: resultNote with keepAspect and an adjusted side", () => {
const tool = TOOLS.find((t) => t.id === "resize")!;
const p = { size: { width: 500, height: 300 }, keepAspect: true };
expect(tool.resultNote?.(p, solid(300, 300))).toEqual({
@@ -370,7 +371,7 @@ describe("registry-new (переведённые инструменты)", () =>
tone: "info",
});
expect(tool.resultNote?.(p, solid(500, 300))).toBeNull();
// без keepAspect результат всегда как запрошено
// without keepAspect the result is always exactly as requested
expect(
tool.resultNote?.(
{ size: { width: 500, height: 300 }, keepAspect: false },
@@ -379,7 +380,7 @@ describe("registry-new (переведённые инструменты)", () =>
).toBeNull();
});
it("fit-on-background: resultNote, когда картинка крупнее канваса", () => {
it("fit-on-background: resultNote when the image is larger than the canvas", () => {
const tool = TOOLS.find((t) => t.id === "fit-on-background")!;
const p = {
size: { width: 100, height: 100 },
@@ -393,7 +394,7 @@ describe("registry-new (переведённые инструменты)", () =>
expect(tool.resultNote?.(p, solid(100, 100))).toBeNull();
});
it("change-canvas-size использует anchor для позиции", async () => {
it("change-canvas-size uses anchor for positioning", async () => {
const tool = TOOLS.find((t) => t.id === "change-canvas-size")!;
const img = solid(10, 10);
const out = asImage(
@@ -413,7 +414,7 @@ describe("registry-new (переведённые инструменты)", () =>
expect(center).toBe(0);
});
it("blend-two: дефолты пары и работа генератора", async () => {
it("blend-two: pair defaults and generator output", async () => {
const tool = TOOLS.find((t) => t.id === "blend-two")!;
const d = defaultSchemaParams(tool.schema);
expect(d.pair).toEqual({ from: "#000000", to: "#ffffff" });
@@ -428,7 +429,7 @@ describe("registry-new (переведённые инструменты)", () =>
expect(img.width).toBe(128);
});
it("step-colors: рендерит steps полос по паре", async () => {
it("step-colors: renders steps stripes from the pair", async () => {
const tool = TOOLS.find((t) => t.id === "step-colors")!;
const img = asImage(
await tool.run({
@@ -443,9 +444,9 @@ describe("registry-new (переведённые инструменты)", () =>
expect(img.width).toBe(128);
});
it("two-colors: перекрашивает светлые/тёмные пиксели по паре", async () => {
it("two-colors: recolors light/dark pixels by the pair", async () => {
const tool = TOOLS.find((t) => t.id === "two-colors")!;
// 50×1, левая половина тёмная, правая светлая
// 50x1, left half dark, right half light
const data = new Uint8ClampedArray(50 * 4).fill(255);
const img = { width: 50, height: 1, data };
for (let x = 0; x < 25; x++) {
@@ -463,7 +464,7 @@ describe("registry-new (переведённые инструменты)", () =>
}),
}),
);
// Светлый пиксель → from (#ffffff), тёмный → to (#ff0000)
// Light pixel -> from (#ffffff), dark pixel -> to (#ff0000)
expect(out.data[0]).toBe(255);
expect(out.data[1]).toBe(0);
expect(out.data[2]).toBe(0);
@@ -472,7 +473,7 @@ describe("registry-new (переведённые инструменты)", () =>
expect(out.data[102]).toBe(255);
});
it("linear-gradient: дефолты gradient и направление по углу", async () => {
it("linear-gradient: gradient defaults and direction by angle", async () => {
const tool = TOOLS.find((t) => t.id === "linear-gradient")!;
const d = defaultSchemaParams(tool.schema);
expect(d.gradient).toEqual({
@@ -480,7 +481,7 @@ describe("registry-new (переведённые инструменты)", () =>
to: "#ffffff",
angle: 0,
});
// 0° — слева направо: крайний левый пиксель = from, крайний правый = to
// 0 deg - left to right: leftmost pixel = from, rightmost = to
let img = asImage(
await tool.run({
params: sanitizeSchemaParams(tool.schema, {
@@ -491,7 +492,7 @@ describe("registry-new (переведённые инструменты)", () =>
);
expect(img.data[0]).toBe(0);
expect(img.data[12]).toBe(255);
// 90° — сверху вниз: верхний пиксель = from, нижний = to
// 90 deg - top to bottom: top pixel = from, bottom = to
img = asImage(
await tool.run({
params: sanitizeSchemaParams(tool.schema, {
@@ -502,7 +503,7 @@ describe("registry-new (переведённые инструменты)", () =>
);
expect(img.data[0]).toBe(0);
expect(img.data[12]).toBe(255);
// 180° — разворот: левый пиксель = to
// 180 deg - reversed: left pixel = to
img = asImage(
await tool.run({
params: sanitizeSchemaParams(tool.schema, {
@@ -515,7 +516,7 @@ describe("registry-new (переведённые инструменты)", () =>
expect(img.data[12]).toBe(0);
});
it("sanitize чинит мусор в gradient и клампит angle в 0..360", () => {
it("sanitize fixes garbage in gradient and clamps angle to 0..360", () => {
const tool = TOOLS.find((t) => t.id === "linear-gradient")!;
const s = sanitizeSchemaParams(tool.schema, {
size: { width: 10, height: 10 },
@@ -533,7 +534,7 @@ describe("registry-new (переведённые инструменты)", () =>
});
});
it("sanitize чинит мусор в паре и клампит threshold", () => {
it("sanitize fixes garbage in the pair and clamps threshold", () => {
const tool = TOOLS.find((t) => t.id === "two-colors")!;
const s = sanitizeSchemaParams(tool.schema, {
pair: { from: "not-a-color", to: "#00ff00", extra: 1 },
@@ -543,7 +544,7 @@ describe("registry-new (переведённые инструменты)", () =>
expect(s.threshold).toBe(100);
});
it("circle-mask: срезает углы и сохраняет центр", async () => {
it("circle-mask: cuts corners and keeps the center", async () => {
const tool = TOOLS.find((t) => t.id === "circle-mask")!;
const img = asImage(
await tool.run({
@@ -554,13 +555,13 @@ describe("registry-new (переведённые инструменты)", () =>
}),
}),
);
// Внешний угол (0,0) — вне круга радиуса 50 → прозрачен
// Outer corner (0,0) is outside the circle of radius 50 -> transparent
expect(img.data[3]).toBe(0);
// Центр (50,50) — внутри
// Center (50,50) is inside
expect(img.data[(50 * img.width + 50) * 4 + 3]).toBe(255);
});
it("circle-mask: offset сдвигает фигуру", async () => {
it("circle-mask: offset shifts the shape", async () => {
const tool = TOOLS.find((t) => t.id === "circle-mask")!;
const img = asImage(
await tool.run({
@@ -571,12 +572,12 @@ describe("registry-new (переведённые инструменты)", () =>
}),
}),
);
// Радиус 25, центр смещён к x=100; пиксель (75,50) внутри, (49,50) снаружи
// Radius 25, center shifted to x=100; pixel (75,50) inside, (49,50) outside
expect(img.data[(50 * img.width + 75) * 4 + 3]).toBe(255);
expect(img.data[(50 * img.width + 49) * 4 + 3]).toBe(0);
});
it("star-mask: сохраняет центр, режет углы", async () => {
it("star-mask: keeps the center, cuts corners", async () => {
const tool = TOOLS.find((t) => t.id === "star-mask")!;
const img = asImage(
await tool.run({
@@ -594,7 +595,7 @@ describe("registry-new (переведённые инструменты)", () =>
expect(img.data[3]).toBe(0);
});
it("sanitize клампит offset к min/max и чинит мусор", () => {
it("sanitize clamps offset to min/max and fixes garbage", () => {
const tool = TOOLS.find((t) => t.id === "wavy-mask")!;
const s = sanitizeSchemaParams(tool.schema, {
size: 90,
@@ -609,7 +610,7 @@ describe("registry-new (переведённые инструменты)", () =>
expect(s.offset).toEqual({ x: 50, y: 0 });
});
it("add-text: дефолты position9, font-style, plate и текстовых полей", () => {
it("add-text: defaults of position9, font-style, plate and text fields", () => {
const tool = TOOLS.find((t) => t.id === "add-text")!;
const d = defaultSchemaParams(tool.schema);
expect(d.position).toBe("bottom-right");
@@ -627,7 +628,7 @@ describe("registry-new (переведённые инструменты)", () =>
});
});
it("sanitize чинит мусор в position9, font-style, plate и оставляет валидные значения", () => {
it("sanitize fixes garbage in position9, font-style, plate and keeps valid values", () => {
const tool = TOOLS.find((t) => t.id === "date-stamp")!;
const s = sanitizeSchemaParams(tool.schema, {
format: "YYYY-MM-DD",
@@ -671,7 +672,7 @@ describe("registry-new (переведённые инструменты)", () =>
});
});
it("from-text: дефолты font-style и sanitize", () => {
it("from-text: font-style defaults and sanitize", () => {
const tool = TOOLS.find((t) => t.id === "from-text")!;
const d = defaultSchemaParams(tool.schema);
expect(d.style).toEqual({
@@ -695,7 +696,7 @@ describe("registry-new (переведённые инструменты)", () =>
});
describe("split-into-parts", () => {
it("объявлен с result=files и дефолтом 2x2", () => {
it("is declared with result=files and a 2x2 default", () => {
const tool = TOOLS.find((t) => t.id === "split-into-parts")!;
expect(tool).toBeDefined();
expect(tool.result).toBe("files");
@@ -704,7 +705,7 @@ describe("split-into-parts", () => {
expect(defaults.rows).toBe(2);
});
it("на ровном размере даёт cols*rows частей одинакового размера", async () => {
it("on an even size returns cols*rows parts of equal size", async () => {
const tool = TOOLS.find((t) => t.id === "split-into-parts")!;
const files = asFiles(
await tool.run({
@@ -728,7 +729,7 @@ describe("split-into-parts", () => {
]);
});
it("на неделимом размере части строго равные (канвас дополняется)", async () => {
it("on an indivisible size parts are strictly equal (canvas is padded)", async () => {
const tool = TOOLS.find((t) => t.id === "split-into-parts")!;
const files = asFiles(
await tool.run({
@@ -746,7 +747,7 @@ describe("split-into-parts", () => {
}
});
it("принимает максимальную сетку 32x32 = 1024 части (потолок стража)", async () => {
it("accepts the max grid 32x32 = 1024 parts (guard ceiling)", async () => {
const tool = TOOLS.find((t) => t.id === "split-into-parts")!;
const files = asFiles(
await tool.run({
@@ -760,7 +761,7 @@ describe("split-into-parts", () => {
expect(files).toHaveLength(1024);
});
it("режим tile: сетка считается от размера тайла, части точные", async () => {
it("tile mode: grid is derived from the tile size, parts are exact", async () => {
const tool = TOOLS.find((t) => t.id === "split-into-parts")!;
const files = asFiles(
await tool.run({
@@ -771,7 +772,7 @@ describe("split-into-parts", () => {
}),
}),
).files;
// 100/32 → 4 колонки, 70/32 → 3 строки
// 100/32 -> 4 columns, 70/32 -> 3 rows
expect(files).toHaveLength(12);
for (const file of files) {
expect(file.image.width).toBe(32);
@@ -779,7 +780,7 @@ describe("split-into-parts", () => {
}
});
it("выбрасывает errors.tooManyParts при переполнении", () => {
it("throws errors.tooManyParts on overflow", () => {
const tool = TOOLS.find((t) => t.id === "split-into-parts")!;
expect(() =>
tool.run({
+26 -25
View File
@@ -10,17 +10,17 @@ const MAP_PATH = fileURLToPath(
const SECTION_START = /^## 1\.\s/;
const SECTION_PLANNED = /^## 2\.\s/;
// Все slug страниц содержат дефис, поэтому формат строгий: это отсекает слова
// описания вроде `median-cut` из «colors (k, median-cut)».
// Every page slug contains a hyphen, so the shape is strict: it excludes
// description words like `median-cut` in "colors (k, median-cut)".
const ID_SHAPE = /^[a-z][a-z0-9]*(-[a-z0-9]+)+$/;
const ID_TOKEN = /[a-z][a-z0-9]*(?:-[a-z0-9]+)+/g;
type Mention = { id: string; line: number };
/**
* Буллиты раздела вместе с номером первой строки. Перенесённые строки
* приклеиваются к буллету: иначе slug, оказавшийся на следующей строке, молча
* выпадал бы из разбора и страница считалась бы неописанной.
* Section bullets with the line number of the first line. Continuation lines
* are glued to the bullet: otherwise a slug wrapped to the next line would
* silently drop out of parsing and the page would count as undocumented.
*/
function sectionBullets(
markdown: string,
@@ -35,8 +35,8 @@ function sectionBullets(
: lines.findIndex((l) => endPattern.test(l));
if (start === -1 || end === -1 || end <= start) {
throw new Error(
`docs/tools-map.md: не найден раздел по ${startPattern} — тест не может ` +
"определить границы разделов карты",
`docs/tools-map.md: section not found by ${startPattern} - the test ` +
"cannot determine the map section boundaries",
);
}
@@ -61,8 +61,9 @@ function sectionBullets(
}
/**
* Всё до первого тире `—` — slug через `/` или `,`; после тире свободное
* описание параметров, оно не разбирается. Формат закреплён в самой карте.
* Everything before the first em dash is slugs separated by `/` or `,`; the
* free-form parameter description after the dash is not parsed. The format is
* pinned by the map itself.
*/
function mentionedIds(markdown: string): Mention[] {
const found: Mention[] = [];
@@ -71,7 +72,7 @@ function mentionedIds(markdown: string): Mention[] {
SECTION_START,
SECTION_PLANNED,
)) {
const head = text.split("—")[0];
const head = text.split("\u2014")[0];
for (const raw of head.split(/[/,]/)) {
const id = raw.trim().replace(/`/g, "");
if (ID_SHAPE.test(id)) found.push({ id, line });
@@ -81,9 +82,9 @@ function mentionedIds(markdown: string): Mention[] {
}
/**
* Плановые разделы «2. Можно добавить» и «3. Идеи» устроены прозой: slug стоит
* где угодно в буллите, после тире и в скобках. Поэтому берём все токены
* нужной формы из всего текста буллита, а не из «головы» до тире.
* Planned sections "2."/"3." are prose: a slug may appear anywhere in the
* bullet, after the dash or in parentheses. Take every token of the required
* shape from the whole bullet, not just the head before the dash.
*/
function plannedIds(markdown: string): Mention[] {
const found: Mention[] = [];
@@ -99,63 +100,63 @@ function plannedIds(markdown: string): Mention[] {
return found;
}
describe("docs/tools-map.md ↔ страницы реестра", () => {
describe("docs/tools-map.md vs registry pages", () => {
const markdown = readFileSync(MAP_PATH, "utf8");
const mentioned = mentionedIds(markdown);
const planned = plannedIds(markdown);
const registrySlugs = new Set(PAGES.map((page) => page.slug));
it("разбирает непустой список slug (парсер не сломался молча)", () => {
it("parses a non-empty slug list (parser did not silently break)", () => {
expect(mentioned.length).toBeGreaterThan(0);
expect(new Set(mentioned.map((m) => m.id)).size).toBeGreaterThan(50);
expect(new Set(planned.map((m) => m.id)).size).toBeGreaterThan(10);
});
it("в карте нет slug, которых нет в реестре", () => {
it("map has no slugs missing from the registry", () => {
const bogus = mentioned.filter((m) => !registrySlugs.has(m.id));
expect(
bogus,
bogus
.map(
(m) =>
`${m.id} (docs/tools-map.md:${m.line}) — такой страницы нет в web/src/lib/registry/pages/`,
`${m.id} (docs/tools-map.md:${m.line}) - no such page in web/src/lib/registry/pages/`,
)
.join("\n"),
).toEqual([]);
});
it("каждая страница реестра описана в карте", () => {
it("every registry page is described in the map", () => {
const mentionedSet = new Set(mentioned.map((m) => m.id));
const missing = [...registrySlugs].filter((id) => !mentionedSet.has(id));
expect(
missing,
`не описаны в разделе «Реализовано»:\n${missing.join("\n")}`,
`not described in the "1. Implemented" section:\n${missing.join("\n")}`,
).toEqual([]);
});
it("slug не продублированы между буллетами", () => {
it("slugs are not duplicated between bullets", () => {
const seen = new Map<string, number>();
const dupes: string[] = [];
for (const { id, line } of mentioned) {
const first = seen.get(id);
if (first !== undefined) {
dupes.push(`${id} (строки ${first} и ${line})`);
dupes.push(`${id} (lines ${first} and ${line})`);
} else {
seen.set(id, line);
}
}
expect(dupes, `повторяющиеся slug:\n${dupes.join("\n")}`).toEqual([]);
expect(dupes, `duplicate slugs:\n${dupes.join("\n")}`).toEqual([]);
});
it("плановый slug уже не реализован (перенос из «2./3.» в «1.»)", () => {
it("planned slugs are not already implemented (move from 2./3. to 1.)", () => {
const stale = planned.filter((m) => registrySlugs.has(m.id));
expect(
stale,
stale
.map(
(m) =>
`${m.id} (docs/tools-map.md:${m.line}) — страница уже есть в реестре, ` +
"перенеси её из планового раздела в «1. Реализовано»",
`${m.id} (docs/tools-map.md:${m.line}) - the page is already in the registry, ` +
'move it from a planned section to "1. Implemented"',
)
.join("\n"),
).toEqual([]);
+2 -2
View File
@@ -424,8 +424,8 @@ interface ChannelParams {
display: "gray" | "color";
}
// Пространства, для которых есть инструмент и страница; тексты страницы живут
// в `registry/pages/color.ts`, поэтому здесь только идентификаторы.
// Spaces that have a tool and a page; page strings live in
// `registry/pages/color.ts`, so only identifiers are kept here.
const CHANNEL_SPACES: SpaceId[] = ["hsl", "hsv", "hsi", "cmyk", "ycbcr", "lab"];
function channelEntries(): Tool<ChannelParams>[] {
+4 -4
View File
@@ -38,9 +38,9 @@ function rgba(hex: string): [number, number, number, number] {
}
/**
* Линейный градиент по произвольному углу. 0° — слева направо, 90° — сверху
* вниз (рост угла по часовой, ось Y вниз). Угол задаёт направление оси
* градиента; t пикселя — нормализованная проекция на эту ось.
* Linear gradient at an arbitrary angle. 0 deg: left to right, 90 deg: top
* to bottom (angle grows clockwise, Y axis down). The angle sets the
* gradient axis direction; a pixel's t is its normalized projection on it.
*/
function angleGradient(
width: number,
@@ -52,7 +52,7 @@ function angleGradient(
const rad = (angle * Math.PI) / 180;
const vx = Math.cos(rad);
const vy = Math.sin(rad);
// Минимум и максимум проекции на ось достигаются в противоположных углах.
// Projection min and max on the axis are reached at opposite corners.
const rightX = vx >= 0 ? width - 1 : 0;
const bottomY = vy >= 0 ? height - 1 : 0;
const projMax = rightX * vx + bottomY * vy;
+13 -52
View File
@@ -4,14 +4,12 @@ import type { OutputMime } from "../core/io";
import type { PixelImage } from "../core/types";
import type { ToolSchema } from "../registry-schema";
/** Формат скачивания, отличный от PNG (bmp/jpeg/webp). */
export interface OutputFormat {
mime: OutputMime;
ext: string;
qualityParamId?: string;
}
/** Чем управляется инструмент. `image` — требуется входное изображение. */
export const INPUT_MODES = {
image: "image",
text: "text",
@@ -19,10 +17,6 @@ export const INPUT_MODES = {
} as const;
export type InputMode = (typeof INPUT_MODES)[keyof typeof INPUT_MODES];
/**
* Тип результата: картинка (по умолчанию), большой текст, короткий вердикт
* или набор файлов (1 → many, скачивается zip-архивом).
*/
export const RESULT_KINDS = {
image: "image",
text: "text",
@@ -31,30 +25,24 @@ export const RESULT_KINDS = {
} as const;
export type ResultKind = (typeof RESULT_KINDS)[keyof typeof RESULT_KINDS];
/** Единый вход инструмента. `source`/`text` присутствуют строго по `input`. */
/** Unified tool input. `source`/`text` are present strictly per `input`. */
export interface ToolContext<P> {
params: P;
source?: PixelImage;
text?: string;
}
/** Один файл мультифайлового результата (1 → many). */
export interface ToolImageFile {
name: string;
image: PixelImage;
}
/** Результат-набор файлов; скачивается zip-архивом. */
/** File-set result (1 -> many); downloaded as a zip archive. */
export interface FileResult {
files: ToolImageFile[];
}
/**
* Вердикт с подстановками: `key` — ключ словаря (`tools[id].results[key]`),
* `vars` — значения для интерполяции `{name}`. Локализация выполняется на
* стороне UI; из run() (и тем более из worker) локализованные строки не
* возвращаются.
*/
/** Verdict: dict `key` + `vars` for `{name}` interpolation; localized on the UI side, never inside run(). */
export interface VerdictResult {
key: string;
vars?: Record<string, string | number>;
@@ -62,64 +50,43 @@ export interface VerdictResult {
export type ToolResult = PixelImage | string | FileResult | VerdictResult;
/** Заметка у тайла результата: i18n-ключ и тон бейджа (warning по умолчанию). */
export type ResultNote = { key: string; tone?: "warning" | "info" };
/**
* Инструмент: уникальная реализация без адреса, текстов и категории — всё это
* у страницы. `id` уникален, но обслуживает сколько угодно страниц, поэтому
* одного инструмента на две страницы хватает, а страница из нескольких
* инструментов — это уже цепочка. Схема обязательна (единый источник
* дефолтов/валидации/UI). У каждого инструмента ровно один метод `run(ctx)`;
* контракт входа декларируется через `input` (обязательное поле), а результат —
* через `result`. Отдельных методов `generate`/`runFromText`/`toText`/
* `textToText` нет.
* Tool: unique implementation without address, strings, or category; those
* belong to the page, and one tool may serve many pages. Schema is required
* (single source of defaults/validation/UI). Exactly one `run(ctx)` method.
*/
export type Tool<P = Record<string, unknown>> = {
/** Внутреннее имя: ключ словаря инструмента, `toolId` worker-протокола, запись пайплайна. */
/** Dict key of the tool, `toolId` of the worker protocol, pipeline record. */
id: string;
schema: ToolSchema<P>;
/** Чем управляется инструмент: входным изображением, текстом или ничем. */
input: InputMode;
/** Тип результата: картинка (по умолчанию), большой текст или короткий вердикт. */
result?: ResultKind;
/** Единственный метод исполнения — и для генераторов, и для трансформаторов. */
run(ctx: ToolContext<P>): Promise<ToolResult> | ToolResult;
/** Требует DOM (canvas/document); превью-executor запускает напрямую, не в worker. */
/** Needs the DOM (canvas/document); the preview executor runs it directly, not in a worker. */
domOnly?: boolean;
/** Формат/качество скачивания результата; по умолчанию — PNG. */
output?: OutputFormat;
/**
* Заметка у тайла результата, когда итог отличается от запрошенного:
* ужат по границам (crop), скорректирован по пропорциям (resize),
* вырос за канвас (fit-on-background).
*/
/** Note at the result tile when the outcome differs from requested (clamped, aspect-corrected, grew past canvas). */
resultNote?: (params: P, result: PixelImage) => ResultNote | null;
};
/** Шаг страницы: инструмент и зафиксированные под него значения параметров. */
export type PageStep = {
id: string;
params?: Record<string, unknown>;
};
/**
* Страница: уникальный адрес, тексты и то, что на ней выполняется. `steps` —
* упорядоченный список инструментов, потому что страница может быть готовой
* цепочкой; исполняет их `SchemaToolView`, и на сегодня шаг ровно один.
*/
/** Page: unique address, strings, and the ordered tool list in `steps` (a page may be a ready-made chain run by `SchemaToolView`). */
export type Page = {
/** Адрес `/tools/<slug>`, имя скачиваемого файла и ключ иконки. */
/** Address `/tools/<slug>`, downloaded file name, and icon key. */
slug: string;
/** EN-строка страницы; перевод лежит в словаре по slug. */
/** EN string; the translation lives in the dictionary by slug. */
title: string;
/** EN-строка страницы; перевод лежит в словаре по slug. */
description: string;
category: CategoryId;
steps: PageStep[];
};
/** Гарантирует наличие входного изображения для трансформаторов. */
export function requireSource<P>(ctx: ToolContext<P>): PixelImage {
if (!ctx.source) {
throw new ToolError("errors.sourceRequired");
@@ -127,7 +94,6 @@ export function requireSource<P>(ctx: ToolContext<P>): PixelImage {
return ctx.source;
}
/** Гарантирует наличие текстового входа для text-инструментов. */
export function requireText<P>(ctx: ToolContext<P>): string {
if (!ctx.text?.trim()) {
throw new ToolError("errors.textRequired");
@@ -135,24 +101,19 @@ export function requireText<P>(ctx: ToolContext<P>): string {
return ctx.text;
}
/**
* Обёртка для image-to-image инструментов: подставляет `src` и `params`
* из контекста. Позволяет оставить тело инструмента в виде `(img, p) => …`.
*/
/** Wrapper for image-to-image tools: injects source and params from the context, keeping the tool body as `(img, p) => ...`. */
export function imgTool<P>(
fn: (img: PixelImage, params: P) => ToolResult | Promise<ToolResult>,
): (ctx: ToolContext<P>) => ToolResult | Promise<ToolResult> {
return (ctx) => fn(requireSource(ctx), ctx.params);
}
/** Обёртка для генераторов: инструменту нужны только параметры. */
export function genTool<P>(
fn: (params: P) => ToolResult | Promise<ToolResult>,
): (ctx: ToolContext<P>) => ToolResult | Promise<ToolResult> {
return (ctx) => fn(ctx.params);
}
/** Обёртка для text-to-image инструментов: подставляет `text` и `params`. */
export function textGen<P>(
fn: (text: string, params: P) => ToolResult | Promise<ToolResult>,
): (ctx: ToolContext<P>) => ToolResult | Promise<ToolResult> {
+3 -3
View File
@@ -1,6 +1,6 @@
/**
* Публичный URL сайта для canonical/OG (абсолютные URL, включая base-path
* GitHub Pages). После покупки домена меняется здесь — `docs/plan-seo.md` §6.
* Внимание: `$app/paths.base` для этого не подходит — он относительный.
* Public site URL for canonical/OG (absolute URLs including the GitHub Pages
* base path). After the domain purchase, change it here; `docs/plan-seo.md` section 6.
* Note: `$app/paths.base` does not work for this; it is relative.
*/
export const SITE_URL = "https://ku6epxboctuk.github.io/easy-png-tools";
+10 -10
View File
@@ -33,8 +33,8 @@ function px(
] as const;
}
describe("Смоук §5 wave3", () => {
it("1. Наклон X/Y по отдельности и вместе искажает форму без чёрных полос", () => {
describe("Smoke wave3 (sec. 5)", () => {
it("1. Skew X/Y separately and together distorts shape without black bands", () => {
const img = solid(20, 20, [200, 30, 30, 255]);
for (const [dx, dy] of [
[15, 0],
@@ -55,7 +55,7 @@ describe("Смоук §5 wave3", () => {
}
});
it("2. Поворот 30°: холст вырос, углы прозрачны, центр на месте", () => {
it("2. Rotate 30 deg: canvas grew, corners transparent, center in place", () => {
const img = solid(100, 100, [255, 0, 0, 255]);
const out = rotateFreeImage(img, 30);
expect(out.width).toBe(136);
@@ -67,7 +67,7 @@ describe("Смоук §5 wave3", () => {
expect(px(out, 67, 67)).toEqual([255, 0, 0, 255]);
});
it("3. Зум 200%: холст прежний, центр совпадает, края обрезаны", () => {
it("3. Zoom 200%: canvas same, center matches, edges cropped", () => {
const grad = makeImage(
11,
11,
@@ -88,14 +88,14 @@ describe("Смоук §5 wave3", () => {
expect([...px(out, 5, 5)]).toEqual([...px(grad, 5, 5)]);
});
it("4. Сдвиг уводит содержимое за край, противоположный край залит фоном", () => {
it("4. Shift moves content off edge, opposite edge filled with background", () => {
const img = solid(4, 4, [0, 0, 255, 255]);
const out = transformImage(img, [1, 0, 0, 1, -2, 0], 4, 4, "#ffffff");
expect(px(out, 0, 0)).toEqual([255, 255, 255, 255]);
expect(px(out, 3, 0)).toEqual([0, 0, 255, 255]);
});
it("5. Гамма: 1 — без изменений, 0.5 темнее, 2 светлее", () => {
it("5. Gamma: 1 - unchanged, 0.5 darker, 2 lighter", () => {
const img = solid(3, 3, [128, 128, 128, 255]);
const id = gammaCorrection(img, 1);
expect([...id.data]).toEqual([...img.data]);
@@ -105,7 +105,7 @@ describe("Смоук §5 wave3", () => {
expect(light).toBeGreaterThan(128);
});
it("6. Автоконтраст вытягивает тусклый диапазон", () => {
it("6. Auto contrast stretches dim range", () => {
const dim = makeImage(2, 2, new Array(4).fill([50, 55, 45, 255]));
dim.data[0] = 40;
const out = autoContrast(dim);
@@ -116,7 +116,7 @@ describe("Смоук §5 wave3", () => {
expect(Math.min(...values)).toBeLessThanOrEqual(1);
});
it("7. Температура ±50 теплеет/холодеет заметно", () => {
it("7. Temperature +/-50 warms/cools noticeably", () => {
const img = solid(2, 2, [128, 128, 128, 255]);
const warm = px(temperature(img, 50), 0, 0);
const cool = px(temperature(img, -50), 0, 0);
@@ -124,7 +124,7 @@ describe("Смоук §5 wave3", () => {
expect(warm[2]).toBeLessThan(cool[2]);
});
it("8. Тонирование красным: белое окрашивается сильнее серого", () => {
it("8. Red tint: white tinted stronger than gray", () => {
const img = makeImage(2, 1, [
[255, 255, 255, 255],
[128, 128, 128, 255],
@@ -136,7 +136,7 @@ describe("Смоук §5 wave3", () => {
expect(gray[0]).toBeGreaterThan(gray[2]);
});
it("9. Виньетка: сила 0 — идентичность, центр не темнеет, углы темнеют", () => {
it("9. Vignette: strength 0 - identity, center not darkened, corners darkened", () => {
const img = solid(21, 21, [200, 200, 200, 255]);
expect([...vignette(img, 0).data]).toEqual([...img.data]);
const out = vignette(img, 60);
+3 -3
View File
@@ -31,9 +31,9 @@ export function setTheme(next: ThemeChoice): void {
}
/**
* Догоняет состояние после гидрации: фактический атрибут уже выставлен
* инлайн-скриптом в app.html; здесь он читается и синхронизируется с runes,
* чтобы кнопка в шапке отражала реальную тему.
* Catches up state after hydration: the actual attribute is already set by
* the inline script in app.html; here it is read and synced with runes so
* the header button reflects the real theme.
*/
export function initTheme(): void {
if (typeof window === "undefined") return;
+4 -4
View File
@@ -29,13 +29,13 @@ beforeEach(() => {
});
describe("initTheme", () => {
it("доверяет атрибуту от анти-вспышки скрипта", () => {
it("trusts attribute from anti-flash script", () => {
stubEnv({ attr: "dark" });
initTheme();
expect(getTheme()).toBe("dark");
});
it("без атрибута берёт системную тему", () => {
it("without attribute uses system theme", () => {
stubEnv({ system: true });
initTheme();
expect(getTheme()).toBe("dark");
@@ -44,7 +44,7 @@ describe("initTheme", () => {
expect(getTheme()).toBe("light");
});
it("мусорный атрибут игнорируется в пользу системной", () => {
it("junk attribute ignored in favor of system", () => {
stubEnv({ attr: "sepia", system: false });
initTheme();
expect(getTheme()).toBe("light");
@@ -52,7 +52,7 @@ describe("initTheme", () => {
});
describe("setTheme", () => {
it("сохраняет выбор и применяет атрибут на html", () => {
it("persists choice and applies attribute on html", () => {
const { store, doc } = stubEnv({});
initTheme();
setTheme("dark");
+2 -2
View File
@@ -17,8 +17,8 @@ export async function buildZipEntries(
}
/**
* Собирает набор картинок (1 → many) в zip-архив и скачивает его.
* Картинки кодируются как PNG; кодирование требует DOM — вызов из UI-слоя.
* Packs a set of images (1 -> many) into a zip archive and downloads it.
* Images are encoded as PNG; encoding needs the DOM, call from the UI layer.
*/
export async function downloadZip(
files: ToolImageFile[],
+2 -2
View File
@@ -33,8 +33,8 @@
let crumb = $derived(toCrumb(page.url.pathname));
// GA подключается только в production-деплое: флаг выставляется в
// deploy-workflow, локальные и preview-сборки тег не получают.
// GA is included only in the production deploy: the flag is set in the
// deploy workflow; local and preview builds do not get the tag.
const gaEnabled = import.meta.env.PUBLIC_GA_ENABLED === "true";
function toCrumb(path: string): string {