From de2a229545e08f2fc613e5e79641ff3cde5d4ac9 Mon Sep 17 00:00:00 2001 From: Ku6epXBOCTuK Date: Sat, 12 Sep 2026 19:19:22 +0500 Subject: [PATCH] chore: copy i18n to v1 for separate --- web/src/lib/v1/i18n/dict.ts | 56 + web/src/lib/v1/i18n/en.ts | 188 +++ web/src/lib/v1/i18n/i18n.test.ts | 82 ++ web/src/lib/v1/i18n/locale.svelte.ts | 58 + web/src/lib/v1/i18n/matching.test.ts | 101 ++ web/src/lib/v1/i18n/matching.ts | 69 + web/src/lib/v1/i18n/ru.ts | 1351 +++++++++++++++++++ web/src/lib/v1/i18n/search-coverage.test.ts | 25 + web/src/lib/v1/i18n/smoke-i18n.test.ts | 96 ++ web/src/lib/v1/i18n/t.ts | 34 + web/src/lib/v1/i18n/tool-strings.ts | 63 + web/src/lib/v1/i18n/tools-i18n.test.ts | 90 ++ 12 files changed, 2213 insertions(+) create mode 100644 web/src/lib/v1/i18n/dict.ts create mode 100644 web/src/lib/v1/i18n/en.ts create mode 100644 web/src/lib/v1/i18n/i18n.test.ts create mode 100644 web/src/lib/v1/i18n/locale.svelte.ts create mode 100644 web/src/lib/v1/i18n/matching.test.ts create mode 100644 web/src/lib/v1/i18n/matching.ts create mode 100644 web/src/lib/v1/i18n/ru.ts create mode 100644 web/src/lib/v1/i18n/search-coverage.test.ts create mode 100644 web/src/lib/v1/i18n/smoke-i18n.test.ts create mode 100644 web/src/lib/v1/i18n/t.ts create mode 100644 web/src/lib/v1/i18n/tool-strings.ts create mode 100644 web/src/lib/v1/i18n/tools-i18n.test.ts diff --git a/web/src/lib/v1/i18n/dict.ts b/web/src/lib/v1/i18n/dict.ts new file mode 100644 index 0000000..382ddc3 --- /dev/null +++ b/web/src/lib/v1/i18n/dict.ts @@ -0,0 +1,56 @@ +import type { CategoryId } from "../categories"; + +export const LOCALES = ["ru", "en"] as const; +export type Locale = (typeof LOCALES)[number]; + +export const BASE_LOCALE: Locale = "en"; + +export const LOCALE_TAGS: Record = { + ru: "ru-RU", + en: "en-US", +}; + +export function isLocale(value: unknown): value is Locale { + return ( + typeof value === "string" && (LOCALES as readonly string[]).includes(value) + ); +} + +export type ToolStrings = { + title?: string; + description?: string; + /** Подписи параметров по их id. */ + params?: Record; + /** Подписи опций select: paramId -> value -> label. */ + options?: Record>; + /** Тексты-результаты текстовых инструментов (analyze): ключ -> строка. */ + results?: Record; +}; + +export type HeaderStrings = { + workspace: string; + catalog: string; + sectionsAria: string; + footerNote: string; +}; + +export type Dict = { + header: HeaderStrings; + categories: Record; + home: Record; + catalog: Record; + toolPage: Record; + chain: Record; + sourceCard: Record; + resultCard: Record; + paramsCard: Record; + textInput: Record; + textResult: Record; + download: Record; + infoPanel: Record; + dropZone: Record; + search: Record; + ui: Record; + errors: Record; + tools: Record; +}; diff --git a/web/src/lib/v1/i18n/en.ts b/web/src/lib/v1/i18n/en.ts new file mode 100644 index 0000000..ee7f100 --- /dev/null +++ b/web/src/lib/v1/i18n/en.ts @@ -0,0 +1,188 @@ +import type { Dict } from "./dict"; + +export const en: Dict = { + header: { + workspace: "Workspace", + catalog: "Catalog", + sectionsAria: "Sections", + footerNote: + "All operations run locally in your browser — your files are never uploaded anywhere.", + }, + categories: { + convert: "Convert", + alpha: "Transparency", + color: "Color", + geometry: "Geometry", + filters: "Filters", + text: "Text", + analyze: "Analyze", + generate: "Generate", + }, + home: { + defaultTitle: "easy-png-tools — PNG utilities right in your browser", + heroTitle: "What do you want to do with the image?", + heroLead: "Find a tool — everything runs locally in your browser.", + restoreLast: "↩ Restore last: {title}", + changeTool: "← Change tool", + }, + catalog: { + pageTitle: "All tools — easy-png-tools", + metaDescription: + "Full catalog of PNG utilities: convert, transparency, color, geometry, analysis and image generation.", + heading: "Tool catalog", + lead: "{count} utilities for working with PNG. Everything runs locally in your browser.", + }, + toolPage: { + fallbackTitle: "Tool", + legendSource: "Source", + legendSummary: "Summary", + legendResult: "Result", + legendParams: "Parameters", + stepHeading: "Step {n}", + removeStepAria: "Remove step", + stepError: "Step {n} ({title}): {msg}", + }, + chain: { + stepLabel: "Step {n}: {title}", + inputLegend: "Input", + resultLegend: "Result", + paramsLegend: "Parameters", + busyTitle: "Processing…", + busyHint: "Running chain step", + removeStepAria: "Remove step", + }, + sourceCard: { + replaceImage: "Replace image", + }, + resultCard: { + emptyTitle: "The result will appear here", + emptyHint: "First upload a source image on the left", + processingTitle: "Processing…", + processingHint: "The image is being processed, this will take a moment", + recalc: "Recalculating…", + nextTool: "⛓ Next tool", + breakChain: "✂ Break the chain", + }, + paramsCard: { + noParams: "This tool has no parameters — the result is ready as is.", + }, + textInput: { + heading: "Text", + placeholder: "Paste data here", + aria: "Text data", + decode: "Decode", + }, + textResult: { + outputAria: "Text result", + copied: "Copied", + copy: "Copy", + downloadTxt: "Download .txt", + }, + download: { + busy: "Preparing file…", + file: "Download .{ext}", + }, + infoPanel: { + dimensions: "Dimensions", + alpha: "Alpha channel", + alphaYes: "yes — there are semi-transparent pixels", + alphaNo: "no", + colorCount: "Unique colors (RGBA)", + }, + dropZone: { + pickDefault: "Drop an image here or click to choose a file", + overlayDefault: "Release the file to replace the image", + }, + search: { + placeholder: "Find a tool…", + aria: "Search tools", + nothingFound: "Nothing found — try another word.", + }, + ui: { + showMask: "Show mask", + decrease: "Decrease", + increase: "Increase", + reset: "Reset", + pipette: "Eyedropper", + themeLight: "Light theme", + themeDark: "Dark theme", + overlayTitle: "Watermark", + overlayDrop: "Drop a watermark PNG or click", + overlayRemove: "Remove watermark", + }, + errors: { + noImageRun: "This tool does not process images", + sourceRequired: "Upload an image first", + textRequired: "Enter text first", + workerFailed: "Worker execution failed", + workerUnavailable: "Worker is unavailable", + notFound: "Tool not found", + noWatermark: "Pick a watermark image first", + badTransform: "Degenerate transformation matrix", + skewAngle: "Skew angles cannot be 90° or -90°", + badHex: 'Invalid HEX color: "{value}"', + radiusInt: "Radius must be a non-negative integer", + kernelSize: "Kernel does not match the image dimensions", + sizeInt: "Width and height must be integers ≥ 1", + cropBounds: "Crop area does not intersect the image", + noCanvasCtx: "Canvas 2D context is unavailable in this environment", + badBase64: "Expected a base64 string or a data-uri of an image", + qualityRange: "quality must be within 0..1", + svgSize: "Could not determine SVG dimensions", + svgLoad: "Failed to load SVG — check the markup", + encodeUnsupported: "The browser does not support encoding to {mime}", + unsupportedFile: + "Unsupported file format ({type}). Supported formats are PNG, JPEG, WebP, GIF and BMP.", + widthInt: "Image width must be an integer ≥ 1", + noHexPixels: "No hex pixel values found", + badPixelToken: + "Each pixel must be 8 hex characters RRGGBBAA, separated by spaces", + pixelCountMismatch: + "Pixel count ({count}) is not divisible by width {width} without a remainder", + toolNotFound: 'Tool "{id}" not found', + badJson: "The file is not valid JSON", + badPipelineShape: "The file structure does not look like a chain of steps", + pipelineVersion: "Unsupported chain version: {version}", + noSteps: "The file has no list of steps", + paramNumber: 'Parameter "{id}" must be a number', + paramString: 'Parameter "{id}" must be a string', + paramBool: 'Parameter "{id}" must be a checkbox value', + resizeSize: "Width and/or height must be positive", + cropSize: "Crop width and height must be positive", + sizePositive: "Dimensions must be positive and finite", + tooManyParts: "Too many parts ({count}). Maximum is 1000.", + }, + tools: { + "png-file-size": { + results: { + line: "PNG size: {kb} KB", + }, + }, + "verify-is-png": { + results: { + verifyYes: "Yes — this is a valid PNG signature.", + verifyNo: "No — the signature does not match a PNG file.", + }, + }, + "png-is-grayscale": { + results: { + grayscaleYes: "Yes — all pixels are shades of gray.", + grayscaleNo: "No — colored pixels were found.", + }, + }, + "png-is-transparent": { + results: { + transparentYes: + "Yes — there are transparent or semi-transparent pixels.", + transparentNo: "No — all pixels are fully opaque.", + }, + }, + "png-orientation": { + results: { + orientationPortrait: "Portrait — height is greater than width.", + orientationLandscape: "Landscape — width is greater than height.", + orientationSquare: "Square — the sides are equal.", + }, + }, + }, +}; diff --git a/web/src/lib/v1/i18n/i18n.test.ts b/web/src/lib/v1/i18n/i18n.test.ts new file mode 100644 index 0000000..25bfd29 --- /dev/null +++ b/web/src/lib/v1/i18n/i18n.test.ts @@ -0,0 +1,82 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { getLocale, initLocale, setLocale } from "./locale.svelte"; +import { interpolate, t } from "./t"; +import { en } from "./en"; + +type Store = Record; + +function stubStorage(): { store: Store } { + const store: Store = {}; + vi.stubGlobal("localStorage", { + getItem: (k: string) => (k in store ? store[k] : null), + setItem: (k: string, v: string) => { + store[k] = v; + }, + }); + vi.stubGlobal("window", {}); + return { store }; +} + +beforeEach(() => { + setLocale("ru"); + delete (en.home as Record).onlyEnKey; + vi.unstubAllGlobals(); +}); + +describe("t", () => { + it("возвращает строку по точечному пути активной локали", () => { + expect(t("header.workspace")).toBe("Рабочая область"); + setLocale("en"); + expect(t("header.catalog")).toBe("Catalog"); + }); + + it("фолбэк на английскую базу, если в активной локали нет ключа", () => { + (en.home as Record).onlyEnKey = "Only English string"; + setLocale("ru"); + expect(t("home.onlyEnKey")).toBe("Only English string"); + }); + + it("неизвестный путь возвращает сам путь", () => { + expect(t("no.such.key")).toBe("no.such.key"); + }); +}); + +describe("interpolate", () => { + it("подставляет переменные в шаблон", () => { + expect(interpolate("Шаг {n} из {total}", { n: 2, total: 5 })).toBe( + "Шаг 2 из 5", + ); + }); + + it("оставляет плейсхолдер без переменной как есть", () => { + expect(interpolate("Привет, {name}!", {})).toBe("Привет, {name}!"); + }); + + it("без переменных возвращает строку без изменений", () => { + expect(interpolate("Просто текст")).toBe("Просто текст"); + }); +}); + +describe("персист локали", () => { + it("initLocale читает сохранённый выбор", () => { + const { store } = stubStorage(); + store["locale"] = "en"; + initLocale(); + expect(getLocale()).toBe("en"); + }); + + it("initLocale игнорирует мусор в хранилище", () => { + const { store } = stubStorage(); + store["locale"] = "fr"; + initLocale(); + expect(getLocale()).toBe("ru"); + }); + + it("setLocale сохраняет выбор в localStorage", () => { + const { store } = stubStorage(); + initLocale(); + setLocale("en"); + expect(store["locale"]).toBe("en"); + expect(getLocale()).toBe("en"); + }); +}); diff --git a/web/src/lib/v1/i18n/locale.svelte.ts b/web/src/lib/v1/i18n/locale.svelte.ts new file mode 100644 index 0000000..d7e1d2f --- /dev/null +++ b/web/src/lib/v1/i18n/locale.svelte.ts @@ -0,0 +1,58 @@ +import { BASE_LOCALE, isLocale, type Dict, type Locale } from "./dict"; +import { ru } from "./ru"; +import { en } from "./en"; + +const STORAGE_KEY = "locale"; + +const DICTS: Record = { ru, en }; + +let locale = $state(BASE_LOCALE); + +function storage(): Storage | null { + return typeof localStorage === "undefined" ? null : localStorage; +} + +export function getLocale(): Locale { + return locale; +} + +export function getDict(): Dict { + return DICTS[locale]; +} + +/** Словарь активной локали; для отсутствующих в ней значений — фолбэк на базовую. */ +export function getMergedDict(): Dict { + if (locale === BASE_LOCALE) return DICTS[BASE_LOCALE]; + const active = DICTS[locale]; + const base = DICTS[BASE_LOCALE]; + return new Proxy(base, { + get(_target, section: string) { + const a = (active as unknown as Record)[section]; + const b = (base as unknown as Record)[section]; + if (a && b && typeof a === "object" && typeof b === "object") { + return { ...(b as object), ...(a as object) }; + } + return a ?? b; + }, + }) as Dict; +} + +export function setLocale(next: Locale): void { + locale = next; + storage()?.setItem(STORAGE_KEY, next); + syncLangAttr(); +} + +/** Читает сохранённый выбор и синхронизирует атрибут lang. Вызывается на клиенте. */ +export function initLocale(): void { + if (typeof window === "undefined") return; + const saved = storage()?.getItem(STORAGE_KEY); + if (isLocale(saved)) locale = saved; + syncLangAttr(); +} + +function syncLangAttr(): void { + if (typeof document !== "undefined") { + document.documentElement.lang = locale; + } +} diff --git a/web/src/lib/v1/i18n/matching.test.ts b/web/src/lib/v1/i18n/matching.test.ts new file mode 100644 index 0000000..a9b48b7 --- /dev/null +++ b/web/src/lib/v1/i18n/matching.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, it } from "vitest"; +import { TOOLS } from "../registry"; +import { setLocale } from "./locale.svelte"; +import { normalizeForSearch, scoreDoc, type SearchDoc } from "./matching"; +import { toolSearchDoc } from "./tool-strings"; + +describe("normalizeForSearch", () => { + it("нижний регистр", () => { + expect(normalizeForSearch("Rotate PNG")).toBe("rotate png"); + }); + + it("ё заменяется на е", () => { + expect(normalizeForSearch("Ёлка и ёж")).toBe("елка и еж"); + }); + + it("диакритика снимается через NFD", () => { + expect(normalizeForSearch("café")).toBe("cafe"); + expect(normalizeForSearch("Über")).toBe("uber"); + }); +}); + +describe("scoreDoc", () => { + const doc: SearchDoc = { + id: "rotate-free-png", + titles: ["Повернуть на произвольный угол"], + descriptions: [ + "Поворот на любой угол. Холст расширяется под новые габариты.", + ], + }; + + it("пустой запрос даёт нейтральный балл", () => { + expect(scoreDoc(doc, "")).toBe(1); + }); + + it("префикс названия ценнее подстроки", () => { + const prefix = scoreDoc(doc, normalizeForSearch("повер")); + const infix = scoreDoc(doc, normalizeForSearch("верну")); + expect(prefix!).toBe(100); + expect(infix!).toBeGreaterThan(30); + }); + + it("подстрока названия ценнее совпадения в описании", () => { + const title = scoreDoc(doc, normalizeForSearch("угол")); + const desc = scoreDoc(doc, normalizeForSearch("габариты")); + expect(title!).toBeGreaterThan(desc!); + }); + + it("нет совпадения — null", () => { + expect(scoreDoc(doc, normalizeForSearch("квант"))).toBeNull(); + }); + + it("запрос с опечаткой ё/е всё равно находит", () => { + expect(scoreDoc(doc, normalizeForSearch("повёрнут"))).not.toBeNull(); + }); +}); + +describe("кросс-языковой поиск на реальном реестре", () => { + it("английский запрос находит инструмент при русской локали", () => { + setLocale("ru"); + const hits = TOOLS.filter((tool) => { + const s = scoreDoc(toolSearchDoc(tool), normalizeForSearch("rotate")); + return s !== null && s > 0; + }).map((tool) => tool.id); + expect(hits).toContain("rotate-png"); + expect(hits).toContain("rotate-free-png"); + }); + + it("русский запрос находит инструмент при английской локали", () => { + setLocale("en"); + try { + for (const q of ["повернуть", "пово"]) { + const hits = TOOLS.filter((tool) => { + const s = scoreDoc(toolSearchDoc(tool), normalizeForSearch(q)); + return s !== null && s > 0; + }).map((tool) => tool.id); + expect(hits, `запрос «${q}» при en-локали`).toContain("rotate-png"); + expect(hits, `запрос «${q}» при en-локали`).toContain( + "rotate-free-png", + ); + } + } finally { + setLocale("ru"); + } + }); + + it("поиск работает и по описанию, не только по названию", () => { + setLocale("en"); + try { + const hits = TOOLS.filter((tool) => { + const s = scoreDoc( + toolSearchDoc(tool), + normalizeForSearch("полупрозрачные"), + ); + return s !== null && s > 0; + }).map((tool) => tool.id); + expect(hits).toContain("png-is-transparent"); + } finally { + setLocale("ru"); + } + }); +}); diff --git a/web/src/lib/v1/i18n/matching.ts b/web/src/lib/v1/i18n/matching.ts new file mode 100644 index 0000000..7f80458 --- /dev/null +++ b/web/src/lib/v1/i18n/matching.ts @@ -0,0 +1,69 @@ +/** + * Нормализация строки для поиска: нижний регистр, ё→е, снятие диакритики (NFD). + */ +export function normalizeForSearch(value: string): string { + return value + .toLowerCase() + .replaceAll("ё", "е") + .normalize("NFD") + .replace(/\p{M}/gu, ""); +} + +export type SearchDoc = { + id: string; + titles: string[]; + descriptions: string[]; +}; + +const SUBSEQUENCE_BASE = 20; + +function bestTitleScore(titles: string[], q: string): number | null { + let best: number | null = null; + for (const raw of titles) { + const title = normalizeForSearch(raw); + if (title.startsWith(q)) return 100; + const at = title.indexOf(q); + if (at >= 0) { + const s = 80 - Math.min(at, 40); + if (best === null || s > best) best = s; + continue; + } + let pos = 0; + let sub = SUBSEQUENCE_BASE; + for (const ch of q) { + pos = title.indexOf(ch, pos); + if (pos < 0) { + sub = -1; + break; + } + sub -= 1; + pos += 1; + } + if (sub >= 0 && (best === null || sub > best)) best = sub; + } + return best; +} + +/** + * Скоринг документа против нормализованного запроса. + * Пустой запрос — нейтральный балл; отсутствие совпадения — null. + */ +export function scoreDoc( + doc: SearchDoc, + normalizedQuery: string, +): number | null { + if (normalizedQuery.length === 0) return 1; + if (normalizeForSearch(doc.id).includes(normalizedQuery)) { + const at = normalizeForSearch(doc.id).indexOf(normalizedQuery); + const byId = 60 - Math.min(at, 40); + const byTitle = bestTitleScore(doc.titles, normalizedQuery); + if (byTitle !== null && byTitle >= 80) return byTitle; + return Math.max(byId, byTitle ?? -1); + } + const byTitle = bestTitleScore(doc.titles, normalizedQuery); + if (byTitle !== null && byTitle >= 20) return byTitle; + for (const raw of doc.descriptions) { + if (normalizeForSearch(raw).includes(normalizedQuery)) return 30; + } + return byTitle !== null && byTitle > 0 ? byTitle : null; +} diff --git a/web/src/lib/v1/i18n/ru.ts b/web/src/lib/v1/i18n/ru.ts new file mode 100644 index 0000000..15b1669 --- /dev/null +++ b/web/src/lib/v1/i18n/ru.ts @@ -0,0 +1,1351 @@ +import type { Dict } from "./dict"; + +export const ru: Dict = { + header: { + workspace: "Рабочая область", + catalog: "Каталог", + sectionsAria: "Разделы", + footerNote: + "Все операции выполняются локально в вашем браузере — файлы никуда не отправляются.", + }, + categories: { + convert: "Конвертация", + alpha: "Прозрачность", + color: "Цвет", + geometry: "Геометрия", + filters: "Фильтры", + text: "Текст", + analyze: "Анализ", + generate: "Генерация", + }, + home: { + defaultTitle: "easy-png-tools — PNG-утилиты прямо в браузере", + heroTitle: "Что делаем с изображением?", + heroLead: + "Найдите инструмент — все операции выполняются локально в браузере.", + restoreLast: "↩ Вернуть последний: {title}", + changeTool: "← Сменить инструмент", + }, + catalog: { + pageTitle: "Все инструменты — easy-png-tools", + metaDescription: + "Полный каталог PNG-утилит: конвертация, прозрачность, цвет, геометрия, анализ и генерация изображений.", + heading: "Каталог инструментов", + lead: "{count} утилит для работы с PNG. Все операции выполняются локально в браузере.", + }, + toolPage: { + fallbackTitle: "Инструмент", + legendSource: "Исходник", + legendSummary: "Сводка", + legendResult: "Результат", + legendParams: "Параметры", + stepHeading: "Шаг {n}", + removeStepAria: "Убрать шаг", + stepError: "Шаг {n} ({title}): {msg}", + }, + chain: { + stepLabel: "Шаг {n}: {title}", + inputLegend: "Вход", + resultLegend: "Результат", + paramsLegend: "Параметры", + busyTitle: "Обработка…", + busyHint: "Выполняется шаг цепочки", + removeStepAria: "Убрать шаг", + }, + sourceCard: { + replaceImage: "Заменить изображение", + }, + resultCard: { + emptyTitle: "Результат появится здесь", + emptyHint: "Сначала загрузите исходное изображение слева", + processingTitle: "Обработка…", + processingHint: "Изображение обрабатывается, это займёт немного времени", + recalc: "Пересчёт…", + nextTool: "⛓ Следующий инструмент", + breakChain: "✂ Оборвать цепочку", + }, + paramsCard: { + noParams: "У этого инструмента нет параметров — результат уже готов.", + }, + textInput: { + heading: "Текст", + placeholder: "Вставьте данные сюда", + aria: "Текстовые данные", + decode: "Декодировать", + }, + textResult: { + outputAria: "Текстовый результат", + copied: "Скопировано", + copy: "Копировать", + downloadTxt: "Скачать .txt", + }, + download: { + busy: "Готовим файл…", + file: "Скачать .{ext}", + }, + infoPanel: { + dimensions: "Размеры", + alpha: "Альфа-канал", + alphaYes: "есть — есть полупрозрачные пиксели", + alphaNo: "нет", + colorCount: "Уникальных цветов (RGBA)", + }, + dropZone: { + pickDefault: "Перетащите изображение сюда или нажмите, чтобы выбрать файл", + overlayDefault: "Отпустите файл, чтобы заменить изображение", + }, + search: { + placeholder: "Найдите инструмент…", + aria: "Поиск инструмента", + nothingFound: "Ничего не найдено — попробуйте другое слово.", + }, + ui: { + showMask: "Показать маску", + decrease: "Уменьшить", + increase: "Увеличить", + reset: "Сбросить", + pipette: "Пипетка", + themeLight: "Светлая тема", + themeDark: "Тёмная тема", + overlayTitle: "Знак", + overlayDrop: "Перетащите PNG-знак или нажмите", + overlayRemove: "Убрать знак", + }, + errors: { + noImageRun: "Этот инструмент не обрабатывает изображения", + sourceRequired: "Сначала загрузите изображение", + textRequired: "Сначала введите текст", + workerFailed: "Ошибка исполнения в воркере", + workerUnavailable: "Воркер недоступен", + notFound: "Инструмент не найден", + noWatermark: "Сначала выберите картинку-знак", + badTransform: "Вырожденная матрица трансформации", + skewAngle: "Углы наклона не могут быть 90° или -90°", + badHex: 'Некорректный HEX-цвет: "{value}"', + radiusInt: "Радиус должен быть целым неотрицательным числом", + kernelSize: "Ядро не совпадает с изображением по размеру", + sizeInt: "Ширина и высота должны быть целыми числами ≥ 1", + cropBounds: "Область обрезки не пересекает изображение", + noCanvasCtx: "Canvas 2D context недоступен в этом окружении", + badBase64: "Ожидается base64-строка или data-uri изображения", + qualityRange: "quality должно быть в диапазоне 0..1", + svgSize: "Не удалось определить размер SVG", + svgLoad: "Не удалось загрузить SVG — проверьте разметку", + encodeUnsupported: "Браузер не поддерживает кодирование в {mime}", + unsupportedFile: + "Неподдерживаемый формат файла ({type}). Поддерживаются PNG, JPEG, WebP, GIF и BMP.", + widthInt: "Ширина изображения должна быть целым числом ≥ 1", + noHexPixels: "Не найдено hex-значений пикселей", + badPixelToken: + "Каждый пиксель — 8 hex-символов RRGGBBAA, значения через пробел", + pixelCountMismatch: + "Число пикселей ({count}) не делится на ширину {width} без остатка", + toolNotFound: 'Инструмент "{id}" не найден', + badJson: "Файл не является корректным JSON", + badPipelineShape: "Структура файла не похожа на цепочку шагов", + pipelineVersion: "Неподдерживаемая версия цепочки: {version}", + noSteps: "В файле нет списка шагов", + paramNumber: 'Параметр "{id}" должен быть числом', + paramString: 'Параметр "{id}" должен быть строкой', + paramBool: 'Параметр "{id}" должен быть значением флажка', + resizeSize: "Ширина и/или высота должны быть положительными", + cropSize: "Ширина и высота области обрезки должны быть положительными", + sizePositive: "Размеры должны быть положительными и конечными", + tooManyParts: "Слишком много частей ({count}). Ограничение — 1000.", + }, + tools: { + "jpg-to-png": { + title: "Конвертировать JPG в PNG", + description: + "Открывает JPEG и сохраняет его как PNG без потерь. Прозрачность, если была, сохраняется.", + }, + "webp-to-png": { + title: "Конвертировать WebP в PNG", + description: "Перекодирует WebP-изображение в универсальный PNG.", + }, + "gif-to-png": { + title: "Конвертировать GIF в PNG", + description: "Достаёт первый кадр GIF-анимации и сохраняет его как PNG.", + }, + "bmp-to-png": { + title: "Конвертировать BMP в PNG", + description: "Перекодирует BMP в компактный PNG без потерь.", + }, + "ico-to-png": { + title: "Конвертировать ICO в PNG", + description: "Превращает иконку .ico в обычный PNG нужного размера.", + }, + "png-to-bmp": { + title: "Конвертировать PNG в BMP", + description: + "Сохраняет изображение в 24-битный BMP без альфа-канала: прозрачность заменяется чёрным фоном.", + }, + "png-to-base64": { + title: "PNG в Base64", + description: + "Кодирует изображение в base64-строку для вставки в код или стили.", + }, + "base64-to-png": { + title: "Base64 в PNG", + description: + "Декодирует base64-строку или data-uri обратно в картинку. Вставьте строку слева.", + }, + "png-to-data-uri": { + title: "PNG в Data URI", + description: + "Строит полный data-uri (data:image/png;base64,…) для встраивания в HTML/CSS.", + }, + "data-uri-to-png": { + title: "Data URI в PNG", + description: "Декодирует data:image/…;base64,… обратно в файл картинки.", + }, + "png-to-hex": { + title: "PNG в HEX-пиксели", + description: + "Показывает все пиксели как hex-значения rrggbbaa — по строкам, через пробел.", + }, + "hex-to-png": { + title: "HEX-пиксели в PNG", + description: + "Собирает картинку из hex-значений rrggbbaa (через пробел). Укажите ширину — высота рассчитается сама.", + params: { width: "Ширина изображения" }, + }, + "resize-png": { + title: "Изменить размер PNG", + description: + "Масштабирование изображения с билинейной интерполяцией. При сохранении пропорций одна сторона задаёт масштаб, а если указаны обе — изображение вписывается в эти размеры.", + params: { + width: "Ширина (0 — авто)", + height: "Высота (0 — авто)", + keepAspect: "Сохранять пропорции", + }, + }, + "crop-png": { + title: "Обрезать PNG", + description: + "Вырезает прямоугольную область. Координаты и размеры выходят за границы изображения — область усекается до пересечения с картинкой.", + params: { + x: "X (слева)", + y: "Y (сверху)", + width: "Ширина области", + height: "Высота области", + }, + }, + "rotate-png": { + title: "Повернуть PNG", + description: + "Поворот на 90°, 180° или 270° по часовой стрелке без потери качества.", + params: { angle: "Угол поворота" }, + options: { + angle: { + "90": "90° по часовой", + "180": "180°", + "270": "270° по часовой", + }, + }, + }, + "flip-png": { + title: "Отразить PNG", + description: + "Зеркальное отражение по горизонтали или вертикали без потери качества.", + params: { axis: "Ось отражения" }, + options: { + axis: { + horizontal: "По горизонтали (слева направо)", + vertical: "По вертикали (сверху вниз)", + }, + }, + }, + "add-padding-png": { + title: "Добавить поля PNG", + description: + "Расширяет холст во все стороны на выбранное число пикселей.", + params: { + padding: "Поля, px", + transparent: "Прозрачные поля", + color: "Цвет полей", + }, + }, + "add-border-png": { + title: "Добавить рамку PNG", + description: "Рисует цветную рамку вокруг изображения выбранной толщины.", + params: { thickness: "Толщина рамки, px", color: "Цвет рамки" }, + }, + "fit-on-background-png": { + title: "Вписать PNG на фон", + description: + "Помещает изображение по центру полотна заданного размера с прозрачным или цветным фоном.", + params: { + width: "Ширина полотна", + height: "Высота полотна", + transparent: "Прозрачный фон", + color: "Цвет фона", + }, + }, + "tile-png": { + title: "Замостить PNG", + description: + "Повторяет изображение сеткой из выбранного числа столбцов и строк.", + params: { columns: "Столбцов", rows: "Строк" }, + }, + "center-by-alpha-png": { + title: "Центрировать PNG по содержимому", + description: + "Находит непрозрачную часть изображения и размещает её по центру прежнего холста.", + }, + "blur-png": { + title: "Размытие PNG", + description: + "Гауссово размытие: три прохода разделяемого бокса — быстро при любом радиусе. Прозрачные края не темнеют.", + params: { radius: "Радиус, px" }, + }, + "sharpen-png": { + title: "Резкость PNG", + description: + "Подчёркивает края ядром резкости; сила задаёт смесь с оригиналом. 0% — без изменений.", + params: { strength: "Сила, %" }, + }, + "grayscale-png": { + title: "Чёрно-белый PNG", + description: + "Переводит изображение в оттенки серого по яркостной формуле BT.601. Альфа сохраняется.", + }, + "invert-colors-png": { + title: "Инвертировать цвета PNG", + description: + "Обращает каждый цветовой канал (255 − значение). Альфа не меняется.", + }, + "adjust-brightness-contrast-png": { + title: "Яркость и контраст PNG", + description: + "Изменяет яркость и контраст в диапазоне от −100 до +100. Значение 0 — без изменений.", + params: { brightness: "Яркость", contrast: "Контраст" }, + }, + "change-png-opacity": { + title: "Изменить прозрачность PNG", + description: + "Умножает альфа-канал на процент: 0% — полностью прозрачный, 100% — без изменений.", + params: { percent: "Прозрачность, %" }, + }, + "sepia-png": { + title: "Эффект сепии", + description: + "Тонирует изображение в тёплые коричневые тона классической сепии.", + }, + "change-png-hue": { + title: "Сменить оттенок PNG", + description: + "Сдвиг цветового тона по кругу. Насыщенность и яркость сохраняются.", + params: { degrees: "Сдвиг тона, °" }, + }, + "extract-channel-png": { + title: "Извлечь канал PNG", + description: + "Оставляет выбранный канал — красный, зелёный или синий — в оттенках серого.", + params: { channel: "Канал" }, + options: { channel: { red: "Красный", green: "Зелёный", blue: "Синий" } }, + }, + "swap-channels-png": { + title: "Переставить каналы PNG", + description: + "Меняет местами два цветовых канала — быстрый способ получить необычный окрас.", + params: { pair: "Пара каналов" }, + options: { + pair: { + "r-g": "Красный ↔ Зелёный", + "r-b": "Красный ↔ Синий", + "g-b": "Зелёный ↔ Синий", + }, + }, + }, + "black-and-white-png": { + title: "Чёрно-белый PNG по порогу", + description: + "Жёсткая бинаризация по яркости: каждый пиксель становится чёрным или белым.", + params: { threshold: "Порог яркости, %" }, + }, + "posterize-png": { + title: "Постеризация PNG", + description: "Уменьшает число уровней каждого канала — плакатный эффект.", + params: { levels: "Уровней на канал" }, + }, + "two-colors-png": { + title: "Два цвета PNG", + description: + "Перекрашивает изображение в два выбранных цвета по порогу яркости.", + params: { + lightColor: "Цвет светлых участков", + darkColor: "Цвет тёмных участков", + threshold: "Порог яркости, %", + }, + }, + "convert-png-to-jpg": { + title: "Конвертировать PNG в JPG", + description: + "Прозрачность накладывается на выбранный цвет подложки (по умолчанию белый), результат сохраняется в JPEG.", + params: { background: "Цвет подложки", quality: "Качество JPEG" }, + }, + "convert-png-to-webp": { + title: "Конвертировать PNG в WebP", + description: + "Перекодирует изображение в WebP с настраиваемым качеством. Прозрачность сохраняется.", + params: { quality: "Качество WebP" }, + }, + "remove-alpha-channel-png": { + title: "Убрать альфа-канал PNG", + description: + "Накладывает изображение на белый фон и сохраняет без прозрачности.", + }, + "set-alpha-channel-png": { + title: "Задать альфа-канал PNG", + description: + "Присваивает всем пикселям одинаковую прозрачность, цвета не меняются.", + params: { percent: "Прозрачность, %" }, + }, + "extract-alpha-mask-png": { + title: "Извлечь маску альфы PNG", + description: "Превращает прозрачность в чёрно-белую непрозрачную маску.", + }, + "round-corners-png": { + title: "Скруглить углы PNG", + description: + "Обрезает углы по радиусу, заданному в процентах от половины меньшей стороны.", + params: { radius: "Радиус скругления, %" }, + }, + "invert-alpha-png": { + title: "Инвертировать альфа-канал PNG", + description: "Непрозрачные области становятся прозрачными и наоборот.", + }, + "remove-background-png": { + title: "Удалить фон PNG (умно)", + description: + "Убирает однотонный фон: по цвету с допуском, только внешние области от краёв или весь совпадающий цвет. Умеет сглаживать границу.", + params: { + color: "Цвет фона", + tolerance: "Допуск похожести, %", + outerOnly: "Только внешние области", + smooth: "Сглаживание границы, проходы", + }, + }, + "add-stroke-png": { + title: "Обвести PNG", + description: + "Добавляет цветную обводку-кольцо вокруг непрозрачного содержимого заданной толщины.", + params: { color: "Цвет обводки", thickness: "Толщина, px" }, + }, + "find-contour-png": { + title: "Найти контур PNG", + description: + "Оставляет только линию по границе непрозрачных областей выбранного цвета и толщины.", + params: { color: "Цвет линии", thickness: "Толщина линии, px" }, + }, + "make-thicker-png": { + title: "Утолщить PNG", + description: "Расширяет непрозрачные области на заданное число пикселей.", + params: { radius: "На сколько px" }, + }, + "make-thinner-png": { + title: "Утончить PNG", + description: + "Сужает непрозрачные области — утоньшает штрихи надписей и деталей.", + params: { radius: "На сколько px" }, + }, + "harden-alpha-png": { + title: "Жёсткие края PNG", + description: + "Бинаризует альфа-канал по порогу: полупрозрачные пиксели становятся либо полностью прозрачными, либо непрозрачными.", + params: { threshold: "Порог альфы, %" }, + }, + "despeckle-alpha-png": { + title: "Убрать мусор PNG", + description: + "Открытие: убирает одиночные полупрозрачные пиксели и мелкие крапинки.", + params: { radius: "Радиус очистки, px" }, + }, + "close-holes-png": { + title: "Закрыть дыры PNG", + description: + "Закрытие: заполняет одиночные прозрачные точки внутри объекта.", + params: { radius: "Радиус закрытия, px" }, + }, + "remove-color-from-png": { + title: "Удалить цвет из PNG (прозрачность)", + description: + "Делает прозрачными все пиксели, близкие к выбранному цвету. Порог задаёт допустимое отклонение в процентах от максимального цветового расстояния.", + params: { + targetColor: "Цвет для удаления", + tolerance: "Порог похожести, %", + }, + }, + "png-info": { + title: "Информация о PNG", + description: + "Показывает размеры, наличие альфа-канала и количество уникальных цветов загруженного изображения.", + }, + "create-empty-png": { + title: "Создать пустой PNG", + description: + "Генерирует холст выбранного размера — прозрачный или залитый цветом.", + params: { + width: "Ширина", + height: "Высота", + transparent: "Прозрачный", + color: "Цвет", + }, + }, + "single-color-png": { + title: "Создать одноцветный PNG", + description: "Генерирует прямоугольник заданного размера и цвета.", + params: { width: "Ширина", height: "Высота", color: "Цвет" }, + }, + "random-noise-png": { + title: "Создать случайный шум PNG", + description: + "Генерирует картинку со случайными пикселями. Зерно фиксирует результат: одно зерно — одна картинка.", + params: { width: "Ширина", height: "Высота", seed: "Зерно" }, + }, + "linear-gradient-png": { + title: "Создать градиент PNG", + description: + "Генерирует плавный переход между двумя цветами по горизонтали или вертикали.", + params: { + width: "Ширина", + height: "Высота", + fromColor: "Цвет начала", + toColor: "Цвет конца", + direction: "Направление", + }, + options: { + direction: { horizontal: "По горизонтали", vertical: "По вертикали" }, + }, + }, + "color-wheel-png": { + title: "Цветовой круг PNG", + description: + "Круг HSL: оттенок по окружности, насыщенность от центра к краю, выбранная светлота.", + params: { width: "Размер", lightness: "Светлота, %" }, + }, + "complementary-png": { + title: "Комплементарная палитра PNG", + description: + "Два противоположных цвета круга — базовый и его дополнение.", + params: { + baseColor: "Базовый цвет", + width: "Ширина", + layout: "Раскладка", + }, + options: { layout: { grid: "Сетка", strip: "Полоса" } }, + }, + "triadic-png": { + title: "Триадная палитра PNG", + description: "Три цвета через 120° друг от друга на цветовом круге.", + params: { + baseColor: "Базовый цвет", + width: "Ширина", + layout: "Раскладка", + }, + options: { layout: { grid: "Сетка", strip: "Полоса" } }, + }, + "tetradic-png": { + title: "Тетрадная палитра PNG", + description: "Четыре цвета — две комплементарные пары, шаг 90° по кругу.", + params: { + baseColor: "Базовый цвет", + width: "Ширина", + layout: "Раскладка", + }, + options: { layout: { grid: "Сетка", strip: "Полоса" } }, + }, + "analogous-png": { + title: "Аналоговая палитра PNG", + description: + "Соседние оттенки вокруг базового — спокойная родственная гамма.", + params: { + baseColor: "Базовый цвет", + width: "Ширина", + layout: "Раскладка", + spread: "Разброс оттенка, °", + count: "Сколько цветов", + }, + options: { layout: { grid: "Сетка", strip: "Полоса" } }, + }, + "monochromatic-png": { + title: "Монохромная палитра PNG", + description: + "Тоны одного оттенка: меняется светлота в выбранном диапазоне, тон и насыщенность фиксированы.", + params: { + baseColor: "Базовый цвет", + width: "Ширина", + layout: "Раскладка", + count: "Сколько цветов", + range: "Диапазон светлоты, %", + }, + options: { layout: { grid: "Сетка", strip: "Полоса" } }, + }, + "shades-png": { + title: "Градация оттенка PNG", + description: "Ступени базового цвета от исходного к более тёмному.", + params: { + baseColor: "Базовый цвет", + width: "Ширина", + layout: "Раскладка", + count: "Сколько цветов", + depth: "Глубина затемнения, %", + }, + options: { layout: { grid: "Сетка", strip: "Полоса" } }, + }, + "mix-colors-png": { + title: "Смешать цвета PNG", + description: + "Усредняет несколько hex-цветов в один свотч. Введите значения через запятую; неверные токены пропускаются.", + params: { colors: "Цвета (hex через запятую)", width: "Ширина" }, + }, + "blend-two-png": { + title: "Перелив двух цветов PNG", + description: "Непрерывный горизонтальный градиент между двумя цветами.", + params: { colorA: "Цвет A", colorB: "Цвет B", width: "Ширина" }, + }, + "step-colors-png": { + title: "Ступени между цветами PNG", + description: + "Дискретный набор равномерно распределённых ступеней между двумя цветами.", + params: { + colorA: "Цвет A", + colorB: "Цвет B", + steps: "Сколько ступеней", + width: "Ширина", + layout: "Раскладка", + }, + options: { layout: { grid: "Сетка", strip: "Полоса" } }, + }, + "sort-colors-png": { + title: "Отсортировать цвета PNG", + description: + "Рисует ваш hex-список свотчами, отсортированными по тону, яркости или насыщенности. Неверные токены пропускаются.", + params: { + colors: "Цвета (hex через запятую)", + order: "Сортировка", + width: "Ширина", + layout: "Раскладка", + }, + options: { + order: { hue: "Оттенок", luma: "Яркость", sat: "Насыщенность" }, + layout: { grid: "Сетка", strip: "Полоса" }, + }, + }, + "png-is-grayscale": { + title: "Проверить: PNG монохромный?", + description: + "Сообщает, состоит ли изображение только из оттенков серого.", + results: { + grayscaleYes: "Да — все пиксели являются оттенками серого.", + grayscaleNo: "Нет — найдены цветные пиксели.", + }, + }, + "png-to-hsl": { + title: "Разложить PNG в HSL", + description: + "Раскладывает изображение на компоненты Тон, Насыщенность и Светлота.", + params: { component: "Компонент", display: "Режим показа" }, + options: { + component: { h: "Тон (H)", s: "Насыщенность (S)", l: "Светлота (L)" }, + display: { gray: "Градациями серого", color: "Пространство как RGB" }, + }, + }, + "png-to-hsv": { + title: "Разложить PNG в HSV", + description: + "Раскладывает изображение на Тон, Насыщенность и Яркость (Value).", + params: { component: "Компонент", display: "Режим показа" }, + options: { + component: { h: "Тон (H)", s: "Насыщенность (S)", v: "Яркость (V)" }, + display: { gray: "Градациями серого", color: "Пространство как RGB" }, + }, + }, + "png-to-hsi": { + title: "Разложить PNG в HSI", + description: + "Раскладывает изображение на Тон, Насыщенность и Интенсивность.", + params: { component: "Компонент", display: "Режим показа" }, + options: { + component: { + h: "Тон (H)", + s: "Насыщенность (S)", + i: "Интенсивность (I)", + }, + display: { gray: "Градациями серого", color: "Пространство как RGB" }, + }, + }, + "png-to-cmyk": { + title: "PNG в CMYK-цвета", + description: + "Раскладывает изображение на печатные компоненты: Голубой, Пурпурный, Жёлтый и Чёрный (Key).", + params: { component: "Компонент", display: "Режим показа" }, + options: { + component: { + c: "Голубой (C)", + m: "Пурпурный (M)", + y: "Жёлтый (Y)", + k: "Чёрный (K)", + }, + display: { gray: "Градациями серого", color: "Пространство как RGB" }, + }, + }, + "png-to-ycbcr": { + title: "PNG в YCbCr-цвета", + description: + "Раскладывает изображение на Яркость (Y) и цветоразностные компоненты Cb / Cr.", + params: { component: "Компонент", display: "Режим показа" }, + options: { + component: { + y: "Яркость (Y)", + cb: "Синий-разностный (Cb)", + cr: "Красный-разностный (Cr)", + }, + display: { gray: "Градациями серого", color: "Пространство как RGB" }, + }, + }, + "png-to-lab": { + title: "PNG в LAB-цвета", + description: + "Раскладывает изображение на perceptual-компоненты: Светлость, зелёный–пурпурный и синий–жёлтый.", + params: { component: "Компонент", display: "Режим показа" }, + options: { + component: { + l: "Светлость (L)", + a: "Зелёный–пурпурный (a)", + b: "Синий–жёлтый (b)", + }, + display: { gray: "Градациями серого", color: "Пространство как RGB" }, + }, + }, + "circle-mask-png": { + title: "Круглая маска PNG", + description: + "Вырезает из изображения круг. Диаметр задаётся в процентах от меньшей стороны.", + params: { + size: "Диаметр, % меньшей стороны", + offsetX: "Смещение X, %", + offsetY: "Смещение Y, %", + }, + }, + "square-mask-png": { + title: "Прямоугольная маска PNG", + description: + "Вырезает прямоугольник со сторонами в процентах от меньшей стороны изображения.", + params: { + widthPct: "Ширина, % меньшей стороны", + heightPct: "Высота, % меньшей стороны", + offsetX: "Смещение X, %", + offsetY: "Смещение Y, %", + }, + }, + "star-mask-png": { + title: "Маска-звезда PNG", + description: + "Вырезает звезду с настраиваемым числом лучей, глубиной впадин и поворотом.", + params: { + points: "Лучи", + innerRadius: "Радиус впадин, %", + size: "Внешний радиус, % меньшей стороны", + rotation: "Поворот, °", + offsetX: "Смещение X, %", + offsetY: "Смещение Y, %", + }, + }, + "wavy-mask-png": { + title: "Волнистая маска PNG", + description: + "Вырезает круг с волнистым краем: радиус модулируется синусом с заданной амплитудой и частотой.", + params: { + size: "Базовый радиус, % меньшей стороны", + amplitude: "Амплитуда волн, %", + waves: "Количество волн", + phase: "Фаза, °", + offsetX: "Смещение X, %", + offsetY: "Смещение Y, %", + }, + }, + "trim-empty-space-png": { + title: "Обрезать пустые поля PNG", + description: + "Обрезает прозрачные рамки вокруг содержимого. Пиксели с альфой выше порога считаются содержимым.", + params: { threshold: "Порог альфы" }, + }, + "change-canvas-size-png": { + title: "Изменить размер холста PNG", + description: + "Задаёт точный размер холста: лишнее обрезается, недостающее дополняется прозрачностью. Якорь выбирает, какая часть изображения остаётся.", + params: { width: "Ширина", height: "Высота", anchor: "Якорь" }, + options: { + anchor: { + "top-left": "Сверху слева", + "top-center": "Сверху по центру", + "top-right": "Сверху справа", + "middle-left": "По центру слева", + center: "По центру", + "middle-right": "По центру справа", + "bottom-left": "Снизу слева", + "bottom-center": "Снизу по центру", + "bottom-right": "Снизу справа", + }, + }, + }, + "change-aspect-ratio-png": { + title: "Изменить соотношение сторон PNG", + description: + "Вписывает изображение в целевое соотношение сторон: обрезать центр до заполнения или дополнить прозрачностью.", + params: { ratio: "Целевое отношение", mode: "Режим" }, + options: { + ratio: { + "1:1": "1:1", + "4:3": "4:3", + "3:4": "3:4", + "3:2": "3:2", + "2:3": "2:3", + "16:9": "16:9", + "9:16": "9:16", + }, + mode: { + crop: "Обрезать до заполнения", + pad: "Дополнить до вписывания", + }, + }, + }, + "swap-orientation-png": { + title: "Поменять ориентацию PNG", + description: + "Поворачивает изображение на 90°, если ориентация отличается от целевой — ландшафт становится портретом и наоборот. Квадрат не трогается.", + params: { target: "Целевая ориентация" }, + options: { target: { portrait: "Портрет", landscape: "Ландшафт" } }, + }, + "symmetric-copy-png": { + title: "Симметричная копия PNG", + description: + "Удваивает холст, зеркалируя сохранённую половину на пустую — мгновенный симметричный узор.", + params: { axis: "Линия зеркала", keepSide: "Какая сторона остаётся" }, + options: { + axis: { + vertical: "Вертикальная (ширина ×2)", + horizontal: "Горизонтальная (высота ×2)", + }, + keepSide: { + left: "Левая", + right: "Правая", + top: "Верхняя", + bottom: "Нижняя", + }, + }, + }, + "feather-edges-png": { + title: "Растушевать края PNG", + description: + "Размывает только альфа-канал: жёсткие края выреза становятся мягкими, цвета не трогаются.", + params: { radius: "Радиус растушёвки, px" }, + }, + "clean-edges-png": { + title: "Почистить края PNG (defringe)", + description: + "Заменяет цветную кайму полупрозрачных пикселей цветом ближайшего полностью непрозрачного соседа. Альфа остаётся как была.", + params: { radius: "Радиус поиска, px" }, + }, + "pixelate-png": { + title: "Пикселизация PNG", + description: + "Усредняет каждый блок blockSize×blockSize в один цвет — классическая мозаика.", + params: { blockSize: "Размер блока, px" }, + }, + "randomize-pixels-png": { + title: "Перемешать пиксели PNG", + description: + "Переставляет блоки изображения между собой. Одинаковый seed даёт одинаковую раскладку.", + params: { blockSize: "Размер блока, px", seed: "Seed" }, + }, + "add-noise-png": { + title: "Добавить шум PNG", + description: + "Добавляет зерно в стиле плёнки. Детерминировано по seed; монохромный сохраняет баланс оттенков.", + params: { amount: "Сила, %", mode: "Тип шума", seed: "Seed" }, + options: { mode: { mono: "Монохромное зерно", color: "Цветной шум" } }, + }, + "silhouette-png": { + title: "Силуэт PNG", + description: + "Заливает все видимые пиксели одним цветом, сохраняя их прозрачность — мгновенный силуэт.", + params: { color: "Цвет силуэта", threshold: "Порог видимости, %" }, + }, + "png-to-bytes": { + title: "PNG в байты", + description: + "Выводит каждый пиксель четырьмя десятичными байтами (R G B A), по строке изображения на строку текста.", + }, + "bytes-to-png": { + title: "Байты в PNG", + description: + "Собирает изображение из десятичных RGBA-байтов (разделители любые). Укажите ширину — высота рассчитается сама.", + params: { width: "Ширина изображения" }, + }, + "png-to-rgb-values": { + title: "PNG в RGB-значения", + description: + "Выводит каждый пиксель как rgba(r, g, b, a), ряды через перевод строки.", + }, + "rgb-values-to-png": { + title: "RGB-значения в PNG", + description: + "Собирает изображение из чисел rgba(r, g, b, a). Укажите ширину — высота рассчитается сама.", + params: { width: "Ширина изображения" }, + }, + "verify-is-png": { + title: "Проверить: это PNG?", + description: + "Проверяет сигнатуру вставленного base64 / data-uri и сообщает, настоящий ли это PNG.", + results: { + verifyYes: "Да — сигнатура настоящего PNG.", + verifyNo: "Нет — сигнатура не совпадает с PNG-файлом.", + }, + }, + "text-to-png": { + title: "Текст в PNG", + description: + "Создаёт PNG из текста: холст подгоняется под размер надписи с полями.", + params: { + text: "Текст", + fontSize: "Размер шрифта, px", + font: "Шрифт", + bold: "Жирный", + color: "Цвет текста", + transparentBg: "Прозрачный фон", + backgroundColor: "Цвет фона", + padding: "Отступ, px", + }, + options: { + font: { + sans: "Без засечек", + serif: "С засечками", + mono: "Моноширинный", + }, + }, + }, + "emoji-to-png": { + title: "Эмодзи в PNG", + description: + "Рисует эмодзи или любой Unicode-символ как прозрачный PNG выбранного размера.", + params: { emoji: "Эмодзи / символ", size: "Размер" }, + }, + "placeholder-png": { + title: "Заглушка PNG", + description: + "Генерирует прямоугольник-заглушку с напечатанными размерами в центре.", + params: { + width: "Ширина", + height: "Высота", + backgroundColor: "Фон", + color: "Цвет текста", + showText: "Печатать размеры", + }, + }, + "color-spectrum-png": { + title: "Спектр цветов PNG", + description: + "Полный радужный переход 0–360° вдоль выбранной оси с настройкой насыщенности и светлоты.", + params: { + width: "Ширина", + height: "Высота", + direction: "Направление", + saturation: "Насыщенность, %", + lightness: "Светлота, %", + }, + options: { + direction: { horizontal: "По горизонтали", vertical: "По вертикали" }, + }, + }, + "random-colors-png": { + title: "Случайные цветные блоки PNG", + description: + "Заполняет холст случайными яркими блоками. Детерминировано по seed.", + params: { + width: "Ширина", + height: "Высота", + blockSize: "Размер блока, px", + seed: "Seed", + }, + }, + "draw-grid-png": { + title: "Нарисовать сетку PNG", + description: + "Рисует сетку с заданными столбцами, строками и толщиной линий на прозрачном или белом фоне.", + params: { + width: "Ширина", + height: "Высота", + cols: "Столбцы", + rows: "Строки", + lineWidth: "Толщина линий, px", + color: "Цвет линий", + transparentBg: "Прозрачный фон", + }, + }, + "quantize-png": { + title: "Квантовать PNG", + description: + "Уменьшает изображение до k цветов через median-cut палитру. Прозрачные пиксели сохраняются.", + params: { colors: "Цветов (k)" }, + }, + "decrease-color-count-png": { + title: "Уменьшить число цветов PNG", + description: + "Тот же движок median-cut с фиксированными пресетами степеней двойки — быстрый спуск до 2–256 цветов.", + params: { maxColors: "Максимум цветов" }, + options: { + maxColors: { + "2": "2", + "4": "4", + "8": "8", + "16": "16", + "32": "32", + "64": "64", + "128": "128", + "256": "256", + }, + }, + }, + "custom-palette-png": { + title: "Своя палитра PNG", + description: + "Сопоставляет каждый пиксель с ближайшим цветом из вашего списка hex через запятую.", + params: { colors: "Палитра (hex через запятую)" }, + }, + "dithering-png": { + title: "Дизеринг PNG", + description: + "Применяет распространение ошибки Флойда–Стейнберга или упорядоченный Байер при сведении к k цветам.", + params: { colors: "Цветов (k)", pattern: "Узор" }, + options: { + pattern: { "floyd-steinberg": "Флойд–Стейнберг", bayer: "Байер 4×4" }, + }, + }, + "compress-png": { + title: "Сжать PNG", + description: + "Уменьшает размер PNG сокращением палитры до выбранного уровня. Честный компромисс: меньше цветов — меньше файл.", + params: { level: "Уровень сжатия" }, + options: { + level: { + light: "Лёгкий (192 цвета)", + balanced: "Сбалансированный (96 цветов)", + strong: "Сильный (44 цвета)", + extreme: "Экстремальный (16 цветов)", + }, + }, + }, + "reduce-to-size-png": { + title: "Вписать PNG в размер", + description: + "Бинарным поиском подбирает размер палитры, чтобы закодированный PNG уложился в целевое количество КБ. Если не влезает даже при 2 цветах — вернёт двухцветный вариант.", + params: { + targetKB: "Целевой размер, КБ", + maxColors: "Максимум цветов для перебора", + }, + }, + "png-file-size": { + title: "Размер файла PNG", + description: + "Кодирует изображение в PNG и показывает итоговый размер файла.", + results: { + line: "Размер PNG: {kb} КБ", + }, + }, + "show-transparent-png": { + title: "Показать прозрачные области PNG", + description: + "Подсвечивает выбранным цветом каждый прозрачный или полупрозрачный пиксель — дыры становятся заметными.", + params: { + mode: "Режим маски", + color: "Цвет подсветки", + opacity: "Непрозрачность подсветки, %", + }, + options: { + mode: { binary: "Чёрно-белая маска", highlight: "Цветная подсветка" }, + }, + }, + "show-grayscale-pixels-png": { + title: "Показать серые пиксели PNG", + description: + "Находит пиксели с почти равными каналами и рисует их маску. Допуск — в единицах канала.", + params: { + tolerance: "Допуск по каналам", + mode: "Режим маски", + color: "Цвет подсветки", + opacity: "Непрозрачность подсветки, %", + }, + options: { + mode: { binary: "Чёрно-белая маска", highlight: "Цветная подсветка" }, + }, + }, + "show-color-pixels-png": { + title: "Показать цветные пиксели PNG", + description: + "Находит цветные (не серые) пиксели за пределами допуска и рисует их маску.", + params: { + tolerance: "Допуск по каналам", + mode: "Режим маски", + color: "Цвет подсветки", + opacity: "Непрозрачность подсветки, %", + }, + options: { + mode: { binary: "Чёрно-белая маска", highlight: "Цветная подсветка" }, + }, + }, + "light-pixel-mask-png": { + title: "Маска светлых пикселей PNG", + description: "Выбирает пиксели ярче порога яркости.", + params: { + threshold: "Порог яркости, %", + mode: "Режим маски", + color: "Цвет подсветки", + opacity: "Непрозрачность подсветки, %", + }, + options: { + mode: { binary: "Чёрно-белая маска", highlight: "Цветная подсветка" }, + }, + }, + "dark-pixel-mask-png": { + title: "Маска тёмных пикселей PNG", + description: "Выбирает пиксели темнее порога яркости.", + params: { + threshold: "Порог яркости, %", + mode: "Режим маски", + color: "Цвет подсветки", + opacity: "Непрозрачность подсветки, %", + }, + options: { + mode: { binary: "Чёрно-белая маска", highlight: "Цветная подсветка" }, + }, + }, + "unique-color-mask-png": { + title: "Маска уникальных цветов PNG", + description: + "Выбирает цвета, которые встречаются не чаще заданного числа раз, — редкие и одиночные пиксели.", + params: { + rarity: "Максимум повторов", + mode: "Режим маски", + color: "Цвет подсветки", + opacity: "Непрозрачность подсветки, %", + }, + options: { + mode: { binary: "Чёрно-белая маска", highlight: "Цветная подсветка" }, + }, + }, + "extract-color-from-png": { + title: "Извлечь цвет из PNG", + description: + "Оставляет только пиксели, близкие к выбранному цвету, остальное делает прозрачным — обратное «Удалить цвет».", + params: { + color: "Какой цвет оставить", + tolerance: "Допуск похожести, %", + }, + }, + "watermark-tile-png": { + title: "Плитка-водяной знак PNG", + description: + "Покрывает изображение повторяющейся диагональной полупрозрачной плиткой текста — защитный водяной знак.", + params: { + text: "Текст", + fontSize: "Размер шрифта, px", + color: "Цвет текста", + opacity: "Непрозрачность, %", + angle: "Угол, °", + stepX: "Шаг X, px", + stepY: "Шаг Y, px", + font: "Шрифт", + bold: "Жирный", + }, + options: { + font: { + sans: "Без засечек", + serif: "С засечками", + mono: "Моноширинный", + }, + }, + }, + "watermark-image-png": { + title: "Знак картинкой PNG", + description: + "Накладывает другой PNG (логотип, подпись): масштаб от ширины холста, прозрачность, позиция 3×3. Знак живёт, пока открыта страница, — после восстановления цепочки выберите его заново.", + params: { + scale: "Ширина знака, % от холста", + opacity: "Непрозрачность, %", + position: "Позиция", + margin: "Отступ, px", + }, + options: { + position: { + "top-left": "Сверху слева", + "top-center": "Сверху по центру", + "top-right": "Сверху справа", + "middle-left": "По центру слева", + center: "По центру", + "middle-right": "По центру справа", + "bottom-left": "Снизу слева", + "bottom-center": "Снизу по центру", + "bottom-right": "Снизу справа", + }, + }, + }, + "add-text-png": { + title: "Надпись на PNG", + description: + "Рисует текст на изображении: шрифт, размер, цвет, жирность, позиция на сетке 3×3 и опциональная подложка.", + params: { + text: "Текст", + fontSize: "Размер шрифта, px", + color: "Цвет текста", + font: "Шрифт", + bold: "Жирный", + position: "Позиция", + margin: "Отступ, px", + plate: "Подложка", + plateColor: "Цвет подложки", + plateOpacity: "Прозрачность плашки, %", + }, + options: { + font: { + sans: "Без засечек", + serif: "С засечками", + mono: "Моноширинный", + }, + position: { + "top-left": "Сверху слева", + "top-center": "Сверху по центру", + "top-right": "Сверху справа", + "middle-left": "По центру слева", + center: "По центру", + "middle-right": "По центру справа", + "bottom-left": "Снизу слева", + "bottom-center": "Снизу по центру", + "bottom-right": "Снизу справа", + }, + }, + }, + "date-stamp-png": { + title: "Дата-штамп PNG", + description: + "Ставит текущую дату и время по строке формата (токены YYYY MM DD hh mm ss). Оформление — как у надписи.", + params: { + format: "Формат", + fontSize: "Размер шрифта, px", + color: "Цвет текста", + font: "Шрифт", + bold: "Жирный", + position: "Позиция", + margin: "Отступ, px", + plate: "Подложка", + plateColor: "Цвет подложки", + plateOpacity: "Прозрачность плашки, %", + }, + options: { + font: { + sans: "Без засечек", + serif: "С засечками", + mono: "Моноширинный", + }, + position: { + "top-left": "Сверху слева", + "top-center": "Сверху по центру", + "top-right": "Сверху справа", + "middle-left": "По центру слева", + center: "По центру", + "middle-right": "По центру справа", + "bottom-left": "Снизу слева", + "bottom-center": "Снизу по центру", + "bottom-right": "Снизу справа", + }, + }, + }, + "skew-png": { + title: "Наклонить PNG", + description: + "Сдвигает содержимое по горизонтали и вертикали — эффект перспективы.", + params: { degX: "Наклон по X, °", degY: "Наклон по Y, °" }, + }, + "rotate-free-png": { + title: "Повернуть на произвольный угол", + description: + "Поворот на любой угол. Холст расширяется под новые габариты, углы остаются прозрачными.", + params: { angle: "Угол, °" }, + }, + "zoom-png": { + title: "Приблизить PNG", + description: + "Увеличивает содержимое к центру. Холст прежнего размера — края обрезаются.", + params: { scale: "Масштаб, %" }, + }, + "shift-png": { + title: "Сдвинуть PNG", + description: "Перемещает содержимое на заданное смещение по X и Y.", + params: { + offsetX: "Смещение X, px", + offsetY: "Смещение Y, px", + color: "Цвет фона", + }, + }, + "vignette-png": { + title: "Виньетка PNG", + description: "Плавно затемняет края изображения, центр не затрагивает.", + params: { strength: "Сила затемнения, %" }, + }, + "jpeg-artifacts-png": { + title: "Артефакты JPEG", + description: + "Имитирует пережатие в JPEG с низким качеством — видимые квадраты и размытие цветов.", + params: { quality: "Качество JPEG" }, + }, + "gamma-png": { + title: "Гамма-коррекция PNG", + description: + "Корректирует яркость средних тонов. <1 темнее, >1 светлее, 1 — без изменений.", + params: { value: "Гамма" }, + }, + "auto-contrast-png": { + title: "Автоконтраст PNG", + description: + "Растягивает диапазон каждого канала на весь доступный диапазон яркости.", + }, + "temperature-png": { + title: "Температура PNG", + description: + "Положительные значения делают изображение теплее (оранжевее), отрицательные — холоднее (синеватее).", + params: { percent: "Температура" }, + }, + "tint-png": { + title: "Тонировать PNG", + description: + "Умножает цветовые каналы на выбранный оттенок с заданной силой.", + params: { color: "Цвет тонирования", strength: "Сила, %" }, + }, + "svg-to-png": { + title: "SVG в PNG", + description: + "Декодирует SVG-разметку в растровое изображение. Вставьте SVG-код слева.", + params: { width: "Ширина результата, px" }, + }, + "png-is-transparent": { + title: "Проверить: PNG прозрачный?", + description: + "Сообщает, есть ли в изображении прозрачные или полупрозрачные пиксели.", + results: { + transparentYes: "Да — есть прозрачные или полупрозрачные пиксели.", + transparentNo: "Нет — все пиксели полностью непрозрачны.", + }, + }, + "png-orientation": { + title: "Ориентация PNG", + description: "Сообщает, портрет это, ландшафт или квадрат.", + results: { + orientationPortrait: "Портрет — высота больше ширины.", + orientationLandscape: "Ландшафт — ширина больше высоты.", + orientationSquare: "Квадрат — стороны равны.", + }, + }, + }, +}; diff --git a/web/src/lib/v1/i18n/search-coverage.test.ts b/web/src/lib/v1/i18n/search-coverage.test.ts new file mode 100644 index 0000000..6fa3419 --- /dev/null +++ b/web/src/lib/v1/i18n/search-coverage.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from "vitest"; +import { isChainable, TOOLS } from "../registry"; +import { normalizeForSearch, scoreDoc } from "./matching"; +import { toolSearchDoc } from "./tool-strings"; + +function hits(q: string): string[] { + const nq = normalizeForSearch(q); + return TOOLS.filter((t) => { + const s = scoreDoc(toolSearchDoc(t), nq); + return s !== null && s > 0; + }).map((t) => t.id); +} + +describe("полнота поиска", () => { + it("генератор (не chainable) находится поиском — раньше отфильтровывался", () => { + expect(isChainable(TOOLS.find((t) => t.id === "color-wheel-png")!)).toBe( + false, + ); + expect(hits("color wheel")).toContain("color-wheel-png"); + }); + + it("анализатор-маска тоже ищется", () => { + expect(hits("уникальных цветов")).toContain("unique-color-mask-png"); + }); +}); diff --git a/web/src/lib/v1/i18n/smoke-i18n.test.ts b/web/src/lib/v1/i18n/smoke-i18n.test.ts new file mode 100644 index 0000000..f1e0833 --- /dev/null +++ b/web/src/lib/v1/i18n/smoke-i18n.test.ts @@ -0,0 +1,96 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { TOOLS } from "../registry"; +import { LOCALE_TAGS } from "./dict"; +import { setLocale } from "./locale.svelte"; +import { normalizeForSearch, scoreDoc } from "./matching"; +import { t } from "./t"; +import { toolSearchDoc } from "./tool-strings"; + +afterEach(() => { + setLocale("ru"); + vi.unstubAllGlobals(); +}); + +describe("Смоук §6 i18n", () => { + it("1. html lang следует за локалью", () => { + const doc = { documentElement: { lang: "" } }; + vi.stubGlobal("document", doc); + setLocale("en"); + expect(doc.documentElement.lang).toBe("en"); + setLocale("ru"); + expect(doc.documentElement.lang).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"], + ]; + for (const [key, ruPart, enPart] of samples) { + setLocale("ru"); + expect(t(key), key + " @ru").toContain(ruPart); + setLocale("en"); + expect(t(key), key + " @en").toContain(enPart); + } + }); + + it("5. Ошибки с vars локализуются на оба языка", () => { + setLocale("ru"); + expect(t("errors.badHex", { value: "#zz" })).toBe( + 'Некорректный HEX-цвет: "#zz"', + ); + expect(t("errors.toolNotFound", { id: "x" })).toContain("не найден"); + setLocale("en"); + expect(t("errors.badHex", { value: "#zz" })).toBe( + 'Invalid HEX color: "#zz"', + ); + }); + + it("6. Поиск кросс-языковой в обе стороны", () => { + const ids = (q: string) => + TOOLS.filter((tool) => { + const s = scoreDoc(toolSearchDoc(tool), normalizeForSearch(q)); + return s !== null && s > 0; + }).map((tool) => tool.id); + + setLocale("ru"); + let hits = ids("rotate"); + expect(hits).toContain("rotate-png"); + hits = ids("повер"); + expect(hits).toContain("rotate-png"); + + setLocale("en"); + hits = ids("пово"); + expect(hits).toContain("rotate-free-png"); + hits = ids("rotate"); + expect(hits).toContain("rotate-png"); + }); + + it("6b. Ё не мешает совпадению", () => { + const blackWhite = TOOLS.find((tool) => tool.id === "black-and-white-png")!; + setLocale("ru"); + const hit = scoreDoc( + toolSearchDoc(blackWhite), + normalizeForSearch("ЧЁРНО"), + ); + expect(hit).not.toBeNull(); + const withoutYo = scoreDoc( + toolSearchDoc(blackWhite), + normalizeForSearch("черно"), + ); + expect(withoutYo).not.toBeNull(); + }); + + it("7. Теги локалей для форматирования чисел корректны", () => { + expect(LOCALE_TAGS.ru).toBe("ru-RU"); + expect(LOCALE_TAGS.en).toBe("en-US"); + }); +}); diff --git a/web/src/lib/v1/i18n/t.ts b/web/src/lib/v1/i18n/t.ts new file mode 100644 index 0000000..f2d3bbb --- /dev/null +++ b/web/src/lib/v1/i18n/t.ts @@ -0,0 +1,34 @@ +import { getMergedDict } from "./locale.svelte"; + +export function interpolate( + template: string, + vars?: Record, +): string { + if (!vars) return template; + return template.replace(/\{(\w+)\}/g, (match, name: string) => + name in vars ? String(vars[name]) : match, + ); +} + +/** + * Перевод по точечному пути вида 'header.workspace' или 'errors.ERR_BAD_HEX'. + * Сначала активная локаль, затем базовая; если ключа нет нигде — возвращается сам путь. + */ +export function t( + path: string, + vars?: Record, +): string { + let node: unknown = getMergedDict(); + for (const part of path.split(".")) { + if ( + node && + typeof node === "object" && + part in (node as Record) + ) { + node = (node as Record)[part]; + } else { + return path; + } + } + return typeof node === "string" ? interpolate(node, vars) : path; +} diff --git a/web/src/lib/v1/i18n/tool-strings.ts b/web/src/lib/v1/i18n/tool-strings.ts new file mode 100644 index 0000000..ffc66ac --- /dev/null +++ b/web/src/lib/v1/i18n/tool-strings.ts @@ -0,0 +1,63 @@ +import type { ParamDef, ToolEntry } from "$lib/v1/registry"; +import { getMergedDict } from "./locale.svelte"; +import type { SearchDoc } from "./matching"; +import { ru } from "./ru"; + +/** + * Строки инструмента на активной локали. + * Русский текст живёт в самом реестре (базовая локаль), + * переводы — в секции tools словарей; отсутствие ключа даёт фолбэк на реестр. + */ +export function toolTitle(tool: ToolEntry): string { + return getMergedDict().tools[tool.id]?.title ?? tool.title; +} + +export function toolDescription(tool: ToolEntry): string { + return getMergedDict().tools[tool.id]?.description ?? tool.description; +} + +export function paramLabel(tool: ToolEntry, param: ParamDef): string { + const viaDict = getMergedDict().tools[tool.id]?.params?.[param.id]; + if (viaDict) return viaDict; + const viaDef = param as { label?: string }; + return viaDef.label ?? param.id; +} + +export function optionLabel( + tool: ToolEntry, + param: ParamDef, + value: string, +): string { + const viaDict = getMergedDict().tools[tool.id]?.options?.[param.id]?.[value]; + if (viaDict) return viaDict; + if (param.type === "select") { + return param.options.find((o) => o.value === value)?.label ?? value; + } + return value; +} + +function dedupe(values: string[]): string[] { + return [...new Set(values.filter((v) => v.length > 0))]; +} + +/** + * Поисковый документ инструмента: строки активной локали, русского и английского + * (реестр) вместе — запрос находит инструмент по любому из языков независимо + * от того, какой сейчас включён. + */ +export function toolSearchDoc(tool: ToolEntry): SearchDoc { + const active = getMergedDict().tools[tool.id]; + return { + id: tool.id, + titles: dedupe([ + active?.title ?? "", + ru.tools[tool.id]?.title ?? "", + tool.title, + ]), + descriptions: dedupe([ + active?.description ?? "", + ru.tools[tool.id]?.description ?? "", + tool.description, + ]), + }; +} diff --git a/web/src/lib/v1/i18n/tools-i18n.test.ts b/web/src/lib/v1/i18n/tools-i18n.test.ts new file mode 100644 index 0000000..8adb4ed --- /dev/null +++ b/web/src/lib/v1/i18n/tools-i18n.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, it } from "vitest"; +import { TOOLS } from "../registry"; +import { en } from "./en"; +import { ru } from "./ru"; + +describe("секция tools словарей покрывает реестр", () => { + it("у каждого инструмента есть перевод ru с непустыми title/description", () => { + for (const tool of TOOLS) { + const strings = ru.tools[tool.id]; + expect(strings, `нет перевода для ${tool.id}`).toBeDefined(); + expect(strings?.title, `${tool.id}: title`).toBeTruthy(); + expect(strings?.description, `${tool.id}: description`).toBeTruthy(); + } + }); + + it("переведены подписи всех параметров", () => { + for (const tool of TOOLS) { + const params = ru.tools[tool.id]?.params ?? {}; + for (const param of tool.params) { + const label = params[param.id]; + expect( + label, + `${tool.id}.${param.id}: нет перевода подписи`, + ).toBeDefined(); + expect(label?.length).toBeGreaterThan(0); + } + } + }); + + it("переведены подписи всех опций select", () => { + for (const tool of TOOLS) { + const options = ru.tools[tool.id]?.options ?? {}; + for (const param of tool.params) { + if (param.type !== "select") continue; + const labels = options[param.id] ?? {}; + for (const option of param.options) { + const label = labels[option.value]; + expect( + label, + `${tool.id}.${param.id}[${option.value}]: нет перевода`, + ).toBeDefined(); + expect(label.length).toBeGreaterThan(0); + } + } + } + }); + + it("в словарях нет лишних ключей инструментов и параметров", () => { + const ids = new Set(TOOLS.map((tool) => tool.id)); + for (const dict of [ru.tools, en.tools]) { + for (const id of Object.keys(dict)) { + expect(ids.has(id), `лишний инструмент в словаре: ${id}`).toBe(true); + const tool = TOOLS.find((t) => t.id === id)!; + const paramIds = new Set(tool.params.map((p) => p.id)); + for (const paramId of Object.keys(dict[id].params ?? {})) { + expect( + paramIds.has(paramId), + `${id}: лишний параметр ${paramId}`, + ).toBe(true); + } + const selectIds = new Set( + tool.params.filter((p) => p.type === "select").map((p) => p.id), + ); + for (const paramId of Object.keys(dict[id].options ?? {})) { + expect( + selectIds.has(paramId), + `${id}: options у не-select параметра ${paramId}`, + ).toBe(true); + } + } + } + }); + + it("тексты-результаты анализаторов переведены в обоих словарях", () => { + const expectations: Array<[string, string[]]> = [ + ["png-is-grayscale", ["grayscaleYes", "grayscaleNo"]], + ["png-is-transparent", ["transparentYes", "transparentNo"]], + [ + "png-orientation", + ["orientationPortrait", "orientationLandscape", "orientationSquare"], + ], + ]; + for (const [id, keys] of expectations) { + for (const key of keys) { + expect(en.tools[id]?.results?.[key], `${id}.${key} (en)`).toBeDefined(); + expect(ru.tools[id]?.results?.[key], `${id}.${key} (ru)`).toBeDefined(); + } + } + }); +});