diff --git a/docs/backlog.md b/docs/backlog.md index 37c3f4e..ad6ea3e 100644 --- a/docs/backlog.md +++ b/docs/backlog.md @@ -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 diff --git a/web/e2e/helpers/fixtures.ts b/web/e2e/helpers/fixtures.ts index fbb9faf..ab3f086 100644 --- a/web/e2e/helpers/fixtures.ts +++ b/web/e2e/helpers/fixtures.ts @@ -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], diff --git a/web/e2e/helpers/i18n.ts b/web/e2e/helpers/i18n.ts new file mode 100644 index 0000000..a40d8f1 --- /dev/null +++ b/web/e2e/helpers/i18n.ts @@ -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; +} diff --git a/web/e2e/helpers/page.ts b/web/e2e/helpers/page.ts index 384b70b..10cdf85 100644 --- a/web/e2e/helpers/page.ts +++ b/web/e2e/helpers/page.ts @@ -76,8 +76,8 @@ export async function renderText(page: Page, value: string): Promise { } export async function uploadImage(page: Page, file: SourceFile): Promise { - // На странице может быть два 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 { - // 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 { } export async function expectNoErrors(sink: ErrorSink): Promise { - expect(sink.errors, "нет console/page errors").toEqual([]); + expect(sink.errors, "no console/page errors").toEqual([]); } diff --git a/web/e2e/i18n.spec.ts b/web/e2e/i18n.spec.ts index 2a4a2a6..50238ea 100644 --- a/web/e2e/i18n.spec.ts +++ b/web/e2e/i18n.spec.ts @@ -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"); diff --git a/web/e2e/png-fixtures.spec.ts b/web/e2e/png-fixtures.spec.ts index 8f18fe6..a887b98 100644 --- a/web/e2e/png-fixtures.spec.ts +++ b/web/e2e/png-fixtures.spec.ts @@ -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); diff --git a/web/e2e/text-and-verdicts.spec.ts b/web/e2e/text-and-verdicts.spec.ts index 6b03488..3201ae9 100644 --- a/web/e2e/text-and-verdicts.spec.ts +++ b/web/e2e/text-and-verdicts.spec.ts @@ -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 () => { diff --git a/web/e2e/tool-flows.spec.ts b/web/e2e/tool-flows.spec.ts index ed62624..26c228c 100644 --- a/web/e2e/tool-flows.spec.ts +++ b/web/e2e/tool-flows.spec.ts @@ -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); diff --git a/web/e2e/tools-smoke.spec.ts b/web/e2e/tools-smoke.spec.ts index ada999c..6f5d076 100644 --- a/web/e2e/tools-smoke.spec.ts +++ b/web/e2e/tools-smoke.spec.ts @@ -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); diff --git a/web/eslint-plugins/README.md b/web/eslint-plugins/README.md index 6c7077b..55990ef 100644 --- a/web/eslint-plugins/README.md +++ b/web/eslint-plugins/README.md @@ -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`. ## Токены-префиксы (целевой словарь дизайна) diff --git a/web/eslint.config.js b/web/eslint.config.js index f825ae1..0268fc6 100644 --- a/web/eslint.config.js +++ b/web/eslint.config.js @@ -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 }, ], }, diff --git a/web/scripts/lint-all.mjs b/web/scripts/lint-all.mjs index 6393137..691032a 100644 --- a/web/scripts/lint-all.mjs +++ b/web/scripts/lint-all.mjs @@ -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", diff --git a/web/src/lib/catalog.ts b/web/src/lib/catalog.ts index 6ec50ad..63038a5 100644 --- a/web/src/lib/catalog.ts +++ b/web/src/lib/catalog.ts @@ -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, diff --git a/web/src/lib/categories.ts b/web/src/lib/categories.ts index c4c1e98..a8025ab 100644 --- a/web/src/lib/categories.ts +++ b/web/src/lib/categories.ts @@ -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[]; diff --git a/web/src/lib/components/Seo.svelte b/web/src/lib/components/Seo.svelte index c32443a..e513123 100644 --- a/web/src/lib/components/Seo.svelte +++ b/web/src/lib/components/Seo.svelte @@ -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(); diff --git a/web/src/lib/components/fields/schema/DimensionField.svelte b/web/src/lib/components/fields/schema/DimensionField.svelte index 00a02ed..fc8b0ad 100644 --- a/web/src/lib/components/fields/schema/DimensionField.svelte +++ b/web/src/lib/components/fields/schema/DimensionField.svelte @@ -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"); } diff --git a/web/src/lib/components/schema/SchemaDownload.svelte b/web/src/lib/components/schema/SchemaDownload.svelte index 70ebccc..f7e88f5 100644 --- a/web/src/lib/components/schema/SchemaDownload.svelte +++ b/web/src/lib/components/schema/SchemaDownload.svelte @@ -39,8 +39,8 @@ let root = $state(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); } diff --git a/web/src/lib/components/schema/SchemaFields.svelte b/web/src/lib/components/schema/SchemaFields.svelte index fde8c1a..f1f5316 100644 --- a/web/src/lib/components/schema/SchemaFields.svelte +++ b/web/src/lib/components/schema/SchemaFields.svelte @@ -58,7 +58,7 @@ schema: ToolSchema>; values: Record; toolId: string; - /** Размеры входа шага: для maxFromSource-полей (crop и т.п.). */ + /** Step input dimensions: for maxFromSource fields (crop, etc.). */ sourceDims?: Dimension; onchange: ( id: string, diff --git a/web/src/lib/components/schema/SchemaResultTile.svelte b/web/src/lib/components/schema/SchemaResultTile.svelte index 6364865..99445f3 100644 --- a/web/src/lib/components/schema/SchemaResultTile.svelte +++ b/web/src/lib/components/schema/SchemaResultTile.svelte @@ -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}` diff --git a/web/src/lib/components/schema/SchemaSourceTile.svelte b/web/src/lib/components/schema/SchemaSourceTile.svelte index f7f06c3..f9d69cf 100644 --- a/web/src/lib/components/schema/SchemaSourceTile.svelte +++ b/web/src/lib/components/schema/SchemaSourceTile.svelte @@ -33,7 +33,7 @@ let dragging = $state(false); let input = $state(null); - // Подсказка замены: показываем на ховер и гасим через 10с до нового захода. + // Replace hint: show on hover, fade after 10s until the next hover. let hintVisible = $state(false); let hintTimer: ReturnType | null = null; diff --git a/web/src/lib/components/schema/SchemaToolView.svelte b/web/src/lib/components/schema/SchemaToolView.svelte index a260b0a..2262bd5 100644 --- a/web/src/lib/components/schema/SchemaToolView.svelte +++ b/web/src/lib/components/schema/SchemaToolView.svelte @@ -76,10 +76,10 @@ const inputMode = $derived(tool.input); let steps = $state([]); - // Поля, изменённые пользователем (по шагам): при загрузке нового файла им не - // даём перезаписаться 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>(); - // Последняя правленая ось dimension-поля — ведущая при lockAspect. + // Last edited axis of a dimension field; leads under lockAspect. const lastAxis: Record = {}; let stepDims = $state<(Dimension | undefined)[]>([]); let stepResults = $state<(PixelImage | null)[]>([]); @@ -96,7 +96,7 @@ let displayError = $state(null); let initedFor = $state(""); let format = $state("image/png"); - // Качество lossy-форматов для инструментов без quality-параметра в схеме. + // Lossy-format quality for tools without a quality param in the schema. let formatQuality = $state>({}); let dragFrom = $state(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 = { ...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; diff --git a/web/src/lib/components/schema/schema-preview-model.test.ts b/web/src/lib/components/schema/schema-preview-model.test.ts index ad0a370..0ff9a3e 100644 --- a/web/src/lib/components/schema/schema-preview-model.test.ts +++ b/web/src/lib/components/schema/schema-preview-model.test.ts @@ -18,12 +18,12 @@ function input(over: Partial = {}) { } 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, diff --git a/web/src/lib/components/schema/schema-preview-model.ts b/web/src/lib/components/schema/schema-preview-model.ts index 930afef..c99c8ec 100644 --- a/web/src/lib/components/schema/schema-preview-model.ts +++ b/web/src/lib/components/schema/schema-preview-model.ts @@ -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); diff --git a/web/src/lib/core/affine.test.ts b/web/src/lib/core/affine.test.ts index bc98413..c2583b3 100644 --- a/web/src/lib/core/affine.test.ts +++ b/web/src/lib/core/affine.test.ts @@ -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); diff --git a/web/src/lib/core/alpha.test.ts b/web/src/lib/core/alpha.test.ts index b672b21..9960228 100644 --- a/web/src/lib/core/alpha.test.ts +++ b/web/src/lib/core/alpha.test.ts @@ -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/); }); }); diff --git a/web/src/lib/core/analyze.test.ts b/web/src/lib/core/analyze.test.ts index c248029..9abb4fc 100644 --- a/web/src/lib/core/analyze.test.ts +++ b/web/src/lib/core/analyze.test.ts @@ -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"); diff --git a/web/src/lib/core/background.test.ts b/web/src/lib/core/background.test.ts index 13c9e8c..ff7e31f 100644 --- a/web/src/lib/core/background.test.ts +++ b/web/src/lib/core/background.test.ts @@ -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]]), { diff --git a/web/src/lib/core/bmp.test.ts b/web/src/lib/core/bmp.test.ts index 9cd88b7..b57b156 100644 --- a/web/src/lib/core/bmp.test.ts +++ b/web/src/lib/core/bmp.test.ts @@ -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], diff --git a/web/src/lib/core/channels.test.ts b/web/src/lib/core/channels.test.ts index 6ad1863..e7465f6 100644 --- a/web/src/lib/core/channels.test.ts +++ b/web/src/lib/core/channels.test.ts @@ -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], diff --git a/web/src/lib/core/channels.ts b/web/src/lib/core/channels.ts index b612a9b..7eb6200 100644 --- a/web/src/lib/core/channels.ts +++ b/web/src/lib/core/channels.ts @@ -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 = { 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, diff --git a/web/src/lib/core/color.test.ts b/web/src/lib/core/color.test.ts index c971526..80a252a 100644 --- a/web/src/lib/core/color.test.ts +++ b/web/src/lib/core/color.test.ts @@ -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]); }); diff --git a/web/src/lib/core/compress.test.ts b/web/src/lib/core/compress.test.ts index 70cd974..33249a4 100644 --- a/web/src/lib/core/compress.test.ts +++ b/web/src/lib/core/compress.test.ts @@ -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++; diff --git a/web/src/lib/core/compress.ts b/web/src/lib/core/compress.ts index 84b5757..c5d0513 100644 --- a/web/src/lib/core/compress.ts +++ b/web/src/lib/core/compress.ts @@ -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, diff --git a/web/src/lib/core/convolution.test.ts b/web/src/lib/core/convolution.test.ts index 5cea4a1..b2c6ae9 100644 --- a/web/src/lib/core/convolution.test.ts +++ b/web/src/lib/core/convolution.test.ts @@ -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], diff --git a/web/src/lib/core/datefmt.test.ts b/web/src/lib/core/datefmt.test.ts index 974a431..2739b4a 100644 --- a/web/src/lib/core/datefmt.test.ts +++ b/web/src/lib/core/datefmt.test.ts @@ -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"); }); }); diff --git a/web/src/lib/core/datefmt.ts b/web/src/lib/core/datefmt.ts index f830b6f..2132d45 100644 --- a/web/src/lib/core/datefmt.ts +++ b/web/src/lib/core/datefmt.ts @@ -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) => { diff --git a/web/src/lib/core/domText.ts b/web/src/lib/core/domText.ts index b9ef61c..8273b91 100644 --- a/web/src/lib/core/domText.ts +++ b/web/src/lib/core/domText.ts @@ -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( diff --git a/web/src/lib/core/effects.test.ts b/web/src/lib/core/effects.test.ts index 393afd3..56489c7 100644 --- a/web/src/lib/core/effects.test.ts +++ b/web/src/lib/core/effects.test.ts @@ -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]; diff --git a/web/src/lib/core/errors.ts b/web/src/lib/core/errors.ts index 004a485..998e84f 100644 --- a/web/src/lib/core/errors.ts +++ b/web/src/lib/core/errors.ts @@ -1,9 +1,9 @@ export type ErrorVars = Record; /** - * Ошибка с стабильным ключом перевода вместо готового текста. - * Ядро бросает только её; человекочитаемый текст подставляет слой 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; diff --git a/web/src/lib/core/gap-geometry.test.ts b/web/src/lib/core/gap-geometry.test.ts index 24d8d83..57c3560 100644 --- a/web/src/lib/core/gap-geometry.test.ts +++ b/web/src/lib/core/gap-geometry.test.ts @@ -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 }); }); diff --git a/web/src/lib/core/gen-tools.test.ts b/web/src/lib/core/gen-tools.test.ts index fbe0f2a..ccdb7d3 100644 --- a/web/src/lib/core/gen-tools.test.ts +++ b/web/src/lib/core/gen-tools.test.ts @@ -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); diff --git a/web/src/lib/core/gen-tools.ts b/web/src/lib/core/gen-tools.ts index 5d64e05..4cc3e06 100644 --- a/web/src/lib/core/gen-tools.ts +++ b/web/src/lib/core/gen-tools.ts @@ -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, diff --git a/web/src/lib/core/generate.test.ts b/web/src/lib/core/generate.test.ts index d7c15f5..695b40a 100644 --- a/web/src/lib/core/generate.test.ts +++ b/web/src/lib/core/generate.test.ts @@ -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, diff --git a/web/src/lib/core/geometry.test.ts b/web/src/lib/core/geometry.test.ts index 5c76ba3..d0fd702 100644 --- a/web/src/lib/core/geometry.test.ts +++ b/web/src/lib/core/geometry.test.ts @@ -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); diff --git a/web/src/lib/core/geometry.ts b/web/src/lib/core/geometry.ts index 5046faf..38bad64 100644 --- a/web/src/lib/core/geometry.ts +++ b/web/src/lib/core/geometry.ts @@ -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, diff --git a/web/src/lib/core/io.test.ts b/web/src/lib/core/io.test.ts index 2f78dfc..9d7e952 100644 --- a/web/src/lib/core/io.test.ts +++ b/web/src/lib/core/io.test.ts @@ -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(); }); diff --git a/web/src/lib/core/io.ts b/web/src/lib/core/io.ts index 1e20eff..a69a3fd 100644 --- a/web/src/lib/core/io.ts +++ b/web/src/lib/core/io.ts @@ -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 }, { diff --git a/web/src/lib/core/masks.test.ts b/web/src/lib/core/masks.test.ts index c8edb55..b3a1284 100644 --- a/web/src/lib/core/masks.test.ts +++ b/web/src/lib/core/masks.test.ts @@ -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); diff --git a/web/src/lib/core/masks.ts b/web/src/lib/core/masks.ts index f10f8c3..819eb9b 100644 --- a/web/src/lib/core/masks.ts +++ b/web/src/lib/core/masks.ts @@ -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, diff --git a/web/src/lib/core/morphology.test.ts b/web/src/lib/core/morphology.test.ts index 20ef0c6..d3c5d86 100644 --- a/web/src/lib/core/morphology.test.ts +++ b/web/src/lib/core/morphology.test.ts @@ -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++) { diff --git a/web/src/lib/core/palette.test.ts b/web/src/lib/core/palette.test.ts index d36cb11..3f9df30 100644 --- a/web/src/lib/core/palette.test.ts +++ b/web/src/lib/core/palette.test.ts @@ -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; diff --git a/web/src/lib/core/palette.ts b/web/src/lib/core/palette.ts index 21c9035..2f89d88 100644 --- a/web/src/lib/core/palette.ts +++ b/web/src/lib/core/palette.ts @@ -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, diff --git a/web/src/lib/core/pixel-fx.test.ts b/web/src/lib/core/pixel-fx.test.ts index fe404c5..0a752d9 100644 --- a/web/src/lib/core/pixel-fx.test.ts +++ b/web/src/lib/core/pixel-fx.test.ts @@ -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()]); diff --git a/web/src/lib/core/pixel-fx.ts b/web/src/lib/core/pixel-fx.ts index 21f0b38..3f01c02 100644 --- a/web/src/lib/core/pixel-fx.ts +++ b/web/src/lib/core/pixel-fx.ts @@ -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, diff --git a/web/src/lib/core/quantize.test.ts b/web/src/lib/core/quantize.test.ts index 664b06f..2939d08 100644 --- a/web/src/lib/core/quantize.test.ts +++ b/web/src/lib/core/quantize.test.ts @@ -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], diff --git a/web/src/lib/core/quantize.ts b/web/src/lib/core/quantize.ts index e8a7d3a..1bdd90e 100644 --- a/web/src/lib/core/quantize.ts +++ b/web/src/lib/core/quantize.ts @@ -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, diff --git a/web/src/lib/core/shapes.test.ts b/web/src/lib/core/shapes.test.ts index 196efbd..ae7c312 100644 --- a/web/src/lib/core/shapes.test.ts +++ b/web/src/lib/core/shapes.test.ts @@ -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), diff --git a/web/src/lib/core/shapes.ts b/web/src/lib/core/shapes.ts index bf363dd..dad8036 100644 --- a/web/src/lib/core/shapes.ts +++ b/web/src/lib/core/shapes.ts @@ -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, diff --git a/web/src/lib/core/text.test.ts b/web/src/lib/core/text.test.ts index 0654da5..2021929 100644 --- a/web/src/lib/core/text.test.ts +++ b/web/src/lib/core/text.test.ts @@ -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(); }); }); diff --git a/web/src/lib/core/textdraw.test.ts b/web/src/lib/core/textdraw.test.ts index d8f9b06..d249639 100644 --- a/web/src/lib/core/textdraw.test.ts +++ b/web/src/lib/core/textdraw.test.ts @@ -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); diff --git a/web/src/lib/core/textdraw.ts b/web/src/lib/core/textdraw.ts index c93bd04..d0cbe00 100644 --- a/web/src/lib/core/textdraw.ts +++ b/web/src/lib/core/textdraw.ts @@ -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, diff --git a/web/src/lib/core/textio.test.ts b/web/src/lib/core/textio.test.ts index 7bbd8b1..178575e 100644 --- a/web/src/lib/core/textio.test.ts +++ b/web/src/lib/core/textio.test.ts @@ -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]); }); diff --git a/web/src/lib/core/textio.ts b/web/src/lib/core/textio.ts index 53221ed..387e439 100644 --- a/web/src/lib/core/textio.ts +++ b/web/src/lib/core/textio.ts @@ -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(); diff --git a/web/src/lib/favorites.svelte.ts b/web/src/lib/favorites.svelte.ts index 51c5562..fc62847 100644 --- a/web/src/lib/favorites.svelte.ts +++ b/web/src/lib/favorites.svelte.ts @@ -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(pages: readonly T[]): T[] { return pages.filter((p) => slugs.has(p.slug)); } -/** Те же страницы, но избранные первыми (бонус в выдаче поиска). */ +/** Same pages, favorites first (a boost in search results). */ export function favFirst(pages: readonly T[]): T[] { return [...pages].sort( (a, b) => Number(slugs.has(b.slug)) - Number(slugs.has(a.slug)), diff --git a/web/src/lib/favorites.test.ts b/web/src/lib/favorites.test.ts index ea5cbeb..731de5c 100644 --- a/web/src/lib/favorites.test.ts +++ b/web/src/lib/favorites.test.ts @@ -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(); }); }); diff --git a/web/src/lib/i18n/dict.ts b/web/src/lib/i18n/dict.ts index 10e3d39..98cadb7 100644 --- a/web/src/lib/i18n/dict.ts +++ b/web/src/lib/i18n/dict.ts @@ -16,19 +16,19 @@ export function isLocale(value: unknown): value is Locale { ); } -/** Тексты страницы (`pages.`): адрес, имя файла и иконка — по slug. */ +/** Page strings (`pages.`): title and description keyed by slug. */ export type PageStrings = { title?: string; description?: string; }; -/** Тексты инструмента (`tools.`): подписи полей, verdict-ключи. */ +/** Tool strings (`tools.`): field labels, verdict keys. */ export type ToolStrings = { - /** Подписи параметров по их id. */ + /** Param labels by param id. */ params?: Record; - /** Подписи опций select: paramId -> value -> label. */ + /** Select option labels: paramId -> value -> label. */ options?: Record>; - /** Тексты-результаты текстовых инструментов (analyze): ключ -> строка. */ + /** Result strings of text tools (analyze): key -> string. */ results?: Record; }; @@ -59,15 +59,15 @@ export type Dict = { search: Record; ui: Record; errors: Record; - /** Заголовки и описания страниц; в базовой локали их нет — берётся EN из реестра. */ + /** Page titles/descriptions; absent in the base locale, EN from the registry is used. */ pages?: Record; tools: Record; - /** Действия панели результата/генерации (Generate, Open image…). */ + /** Result/generation panel actions (Generate, Open image...). */ actions?: Record; - /** Поля ввода текста нового UI (placeholder, Try sample…). */ + /** Text input fields of the new UI (placeholder, Try sample...). */ textSource?: Record; - /** Подписи полей по ключу (решение C: label-ключ на поле). */ + /** Field labels by key (decision C: label key on the field). */ fields?: Record; - /** Заголовки групп полей по ключу (решение A). */ + /** Field group titles by key (decision A). */ groups?: Record; }; diff --git a/web/src/lib/i18n/en.ts b/web/src/lib/i18n/en.ts index b1d562c..60f9358 100644 --- a/web/src/lib/i18n/en.ts +++ b/web/src/lib/i18n/en.ts @@ -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": { diff --git a/web/src/lib/i18n/i18n.test.ts b/web/src/lib/i18n/i18n.test.ts index e47215d..26a8fb7 100644 --- a/web/src/lib/i18n/i18n.test.ts +++ b/web/src/lib/i18n/i18n.test.ts @@ -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; @@ -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).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"); diff --git a/web/src/lib/i18n/locale.svelte.ts b/web/src/lib/i18n/locale.svelte.ts index d7e1d2f..7d4b065 100644 --- a/web/src/lib/i18n/locale.svelte.ts +++ b/web/src/lib/i18n/locale.svelte.ts @@ -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); diff --git a/web/src/lib/i18n/matching.test.ts b/web/src/lib/i18n/matching.test.ts index b17b71c..cba9490 100644 --- a/web/src/lib/i18n/matching.test.ts +++ b/web/src/lib/i18n/matching.test.ts @@ -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(); }); }); diff --git a/web/src/lib/i18n/matching.ts b/web/src/lib/i18n/matching.ts index 7f80458..66e3af7 100644 --- a/web/src/lib/i18n/matching.ts +++ b/web/src/lib/i18n/matching.ts @@ -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, diff --git a/web/src/lib/i18n/schema-tool-strings.test.ts b/web/src/lib/i18n/schema-tool-strings.test.ts index 9bd6618..e3b0b0f 100644 --- a/web/src/lib/i18n/schema-tool-strings.test.ts +++ b/web/src/lib/i18n/schema-tool-strings.test.ts @@ -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) => { - setLocale("ru"); - expect(verdictText(toolId, key)).toBe(ruText); - setLocale("en"); - expect(verdictText(toolId, key)).toBe(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); + }); }); diff --git a/web/src/lib/i18n/schema-tool-strings.ts b/web/src/lib/i18n/schema-tool-strings.ts index 19b3e06..f346459 100644 --- a/web/src/lib/i18n/schema-tool-strings.ts +++ b/web/src/lib/i18n/schema-tool-strings.ts @@ -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()); diff --git a/web/src/lib/i18n/smoke-i18n.test.ts b/web/src/lib/i18n/smoke-i18n.test.ts index c30876e..9e80717 100644 --- a/web/src/lib/i18n/smoke-i18n.test.ts +++ b/web/src/lib/i18n/smoke-i18n.test.ts @@ -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"); }); diff --git a/web/src/lib/i18n/t.ts b/web/src/lib/i18n/t.ts index f2d3bbb..acd8d84 100644 --- a/web/src/lib/i18n/t.ts +++ b/web/src/lib/i18n/t.ts @@ -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, diff --git a/web/src/lib/i18n/tools-i18n.test.ts b/web/src/lib/i18n/tools-i18n.test.ts index e8a03ea..519d636 100644 --- a/web/src/lib/i18n/tools-i18n.test.ts +++ b/web/src/lib/i18n/tools-i18n.test.ts @@ -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(); const usedGroups = new Set(); 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); } } }); diff --git a/web/src/lib/palette.test.ts b/web/src/lib/palette.test.ts index 01dbf11..24ab99f 100644 --- a/web/src/lib/palette.test.ts +++ b/web/src/lib/palette.test.ts @@ -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"), diff --git a/web/src/lib/pipeline.svelte.ts b/web/src/lib/pipeline.svelte.ts index 11c2ca8..a8b3728 100644 --- a/web/src/lib/pipeline.svelte.ts +++ b/web/src/lib/pipeline.svelte.ts @@ -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 || diff --git a/web/src/lib/pipeline.test.ts b/web/src/lib/pipeline.test.ts index cd3a809..743a1bd 100644 --- a/web/src/lib/pipeline.test.ts +++ b/web/src/lib/pipeline.test.ts @@ -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(); }); }); diff --git a/web/src/lib/registry-schema.test.ts b/web/src/lib/registry-schema.test.ts index 3fcb9ff..71654c8 100644 --- a/web/src/lib/registry-schema.test.ts +++ b/web/src/lib/registry-schema.test.ts @@ -55,8 +55,8 @@ const sourceSchema = toolSchema({ 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({ 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( { 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({ 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", diff --git a/web/src/lib/registry-schema.ts b/web/src/lib/registry-schema.ts index dfd2a01..68eee7f 100644 --- a/web/src/lib/registry-schema.ts +++ b/web/src/lib/registry-schema.ts @@ -1,22 +1,19 @@ -// Типизированная схема параметров для нового UI. -// -// Связка типов на этапе компиляции (защита от рассинхрона): -// - `field.x()` возвращает `Field` — phantom `__fieldT` связывает -// runtime-спецификацию поля с ожидаемым типом значения (`number`|`string`|`boolean`). -// - `toolSchema

(fields)` требует, чтобы ключи `fields` в точности совпадали -// с полями `P`, а `Field` тип-проверялся на соответствие `P[K]`. -// Ошибки компилятора ловят расхождения между интерфейсом Params и схемой. +// Typed parameter schema for the UI. +// Compile-time type binding (drift protection): `field.x()` returns +// `Field` whose phantom `__fieldT` ties the runtime spec to the expected +// value type; `toolSchema

(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 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 = (typeof fieldSpecs)[K]; export type FieldSpec = FieldSpecOf; -/** `Field`: runtime-спека поля + phantom-тип ожидаемого значения (number|string|boolean). */ +/** `Field`: runtime field spec + phantom type of the expected value (number|string|boolean). */ export interface Field { 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

` из объявленных полей. - * Компилятор проверяет: ключи `fields` === полям `P`, типы полей совместимы с `P[K]`. + * Builds a `ToolSchema

` from declared fields. + * The compiler checks: `fields` keys === keys of `P`, field types match `P[K]`. */ export function toolSchema

( fields: { [K in keyof P]: Field }, @@ -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

( schema: ToolSchema

, params: Record, @@ -478,8 +474,8 @@ export function clampSourceAwareMaxes

( } /** - * Пересчёт при включённом 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

( schema: ToolSchema

, context?: SchemaContext, @@ -546,9 +542,9 @@ export function defaultSchemaParams

( } /** - * Пересчитывает дефолты от нового исходника, сохраняя значения пользователя. - * Поля из 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

( schema: ToolSchema

, @@ -568,7 +564,7 @@ export function applySourceDefaults

( return defaults; } -/** Валидация/нормализация значений по схеме. Аналог старого `sanitizeParams`. */ +/** Validation/normalization of values against the schema. Successor of `sanitizeParams`. */ export function sanitizeSchemaParams

( schema: ToolSchema

, values: Record, diff --git a/web/src/lib/registry/index.ts b/web/src/lib/registry/index.ts index a1226f1..c56c2c4 100644 --- a/web/src/lib/registry/index.ts +++ b/web/src/lib/registry/index.ts @@ -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); } diff --git a/web/src/lib/registry/registry.test.ts b/web/src/lib/registry/registry.test.ts index 375e509..e90e610 100644 --- a/web/src/lib/registry/registry.test.ts +++ b/web/src/lib/registry/registry.test.ts @@ -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 = { "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) => { - 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); - }); + ])( + "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({ diff --git a/web/src/lib/registry/tools-map-doc.test.ts b/web/src/lib/registry/tools-map-doc.test.ts index 388a37d..0c2898c 100644 --- a/web/src/lib/registry/tools-map-doc.test.ts +++ b/web/src/lib/registry/tools-map-doc.test.ts @@ -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(); 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([]); diff --git a/web/src/lib/registry/tools/color.ts b/web/src/lib/registry/tools/color.ts index 93e2490..456e169 100644 --- a/web/src/lib/registry/tools/color.ts +++ b/web/src/lib/registry/tools/color.ts @@ -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[] { diff --git a/web/src/lib/registry/tools/generate.ts b/web/src/lib/registry/tools/generate.ts index 4360bae..f24941e 100644 --- a/web/src/lib/registry/tools/generate.ts +++ b/web/src/lib/registry/tools/generate.ts @@ -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; diff --git a/web/src/lib/registry/types.ts b/web/src/lib/registry/types.ts index e2ad291..2ebbabe 100644 --- a/web/src/lib/registry/types.ts +++ b/web/src/lib/registry/types.ts @@ -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

{ 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; @@ -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

> = { - /** Внутреннее имя: ключ словаря инструмента, `toolId` worker-протокола, запись пайплайна. */ + /** Dict key of the tool, `toolId` of the worker protocol, pipeline record. */ id: string; schema: ToolSchema

; - /** Чем управляется инструмент: входным изображением, текстом или ничем. */ input: InputMode; - /** Тип результата: картинка (по умолчанию), большой текст или короткий вердикт. */ result?: ResultKind; - /** Единственный метод исполнения — и для генераторов, и для трансформаторов. */ run(ctx: ToolContext

): Promise | 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; }; -/** - * Страница: уникальный адрес, тексты и то, что на ней выполняется. `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/`, имя скачиваемого файла и ключ иконки. */ + /** Address `/tools/`, 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

(ctx: ToolContext

): PixelImage { if (!ctx.source) { throw new ToolError("errors.sourceRequired"); @@ -127,7 +94,6 @@ export function requireSource

(ctx: ToolContext

): PixelImage { return ctx.source; } -/** Гарантирует наличие текстового входа для text-инструментов. */ export function requireText

(ctx: ToolContext

): string { if (!ctx.text?.trim()) { throw new ToolError("errors.textRequired"); @@ -135,24 +101,19 @@ export function requireText

(ctx: ToolContext

): 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

( fn: (img: PixelImage, params: P) => ToolResult | Promise, ): (ctx: ToolContext

) => ToolResult | Promise { return (ctx) => fn(requireSource(ctx), ctx.params); } -/** Обёртка для генераторов: инструменту нужны только параметры. */ export function genTool

( fn: (params: P) => ToolResult | Promise, ): (ctx: ToolContext

) => ToolResult | Promise { return (ctx) => fn(ctx.params); } -/** Обёртка для text-to-image инструментов: подставляет `text` и `params`. */ export function textGen

( fn: (text: string, params: P) => ToolResult | Promise, ): (ctx: ToolContext

) => ToolResult | Promise { diff --git a/web/src/lib/seo.ts b/web/src/lib/seo.ts index 24c40f9..48e3cb4 100644 --- a/web/src/lib/seo.ts +++ b/web/src/lib/seo.ts @@ -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"; diff --git a/web/src/lib/smoke-wave3.test.ts b/web/src/lib/smoke-wave3.test.ts index 9e75b35..9148fe9 100644 --- a/web/src/lib/smoke-wave3.test.ts +++ b/web/src/lib/smoke-wave3.test.ts @@ -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); diff --git a/web/src/lib/theme.svelte.ts b/web/src/lib/theme.svelte.ts index 09c3fb7..071db80 100644 --- a/web/src/lib/theme.svelte.ts +++ b/web/src/lib/theme.svelte.ts @@ -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; diff --git a/web/src/lib/theme.test.ts b/web/src/lib/theme.test.ts index 798be5d..8c45de1 100644 --- a/web/src/lib/theme.test.ts +++ b/web/src/lib/theme.test.ts @@ -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"); diff --git a/web/src/lib/zip.ts b/web/src/lib/zip.ts index 130401f..aae5134 100644 --- a/web/src/lib/zip.ts +++ b/web/src/lib/zip.ts @@ -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[], diff --git a/web/src/routes/+layout.svelte b/web/src/routes/+layout.svelte index 63b9a14..3b74e74 100644 --- a/web/src/routes/+layout.svelte +++ b/web/src/routes/+layout.svelte @@ -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 {