Compare commits
6
Commits
2301b0e165
...
b296b6fa20
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b296b6fa20 | ||
|
|
486b99dbb8 | ||
|
|
c04a1fc1dc | ||
|
|
6787201425 | ||
|
|
396036802c | ||
|
|
74a18d91a7 |
@@ -1,6 +1,6 @@
|
|||||||
# План: i18n — русский и английский
|
# План: i18n — русский и английский
|
||||||
|
|
||||||
> Статус: план к выполнению.
|
> **СТАТУС: ВЫПОЛНЕН 25.08.2026.** Базовая локаль — английский (реестр), русский — перевод в словаре; строки инструментов в реестре переведены на английский в рамках этапа C.
|
||||||
|
|
||||||
## 1. Что получается
|
## 1. Что получается
|
||||||
|
|
||||||
@@ -12,7 +12,7 @@
|
|||||||
|
|
||||||
- Без сегментов языка в URL. Статика пререндерится на русском; переключение на клиенте, выбор живёт в localStorage. hreflang/canonical и вторая копия страниц для поисковиков — не в этой волне.
|
- Без сегментов языка в URL. Статика пререндерится на русском; переключение на клиенте, выбор живёт в localStorage. hreflang/canonical и вторая копия страниц для поисковиков — не в этой волне.
|
||||||
- Ядро не знает о локалях. Ошибки в core выбрасываются стабильными кодами (например ERR_BAD_HEX); перевод кода в человекочитаемый текст происходит в слое исполнителя/UI.
|
- Ядро не знает о локалях. Ошибки в core выбрасываются стабильными кодами (например ERR_BAD_HEX); перевод кода в человекочитаемый текст происходит в слое исполнителя/UI.
|
||||||
- Строки реестра живут в словарях, а не инлайн: словарь содержит секцию tools, ключ — id инструмента, внутри title, description, labels параметров и label'ы опций select. Реестр хранит только схему параметров и логику run.
|
- Английский — базовая локаль и фолбэк: строки инструментов в реестре записаны по-английски. Переводы живут в секции tools словарей по ключу id инструмента (title, description, подписи параметров, подписи опций select, тексты-результаты анализаторов); сейчас перевод — русский в ru.ts. Доступ из компонентов только через хелперы toolTitle/toolDescription/paramLabel/optionLabel. Так каждая строка имеет единственный источник: en — реестр, переводы — словари.
|
||||||
|
|
||||||
## 3. Структура i18n-модуля
|
## 3. Структура i18n-модуля
|
||||||
|
|
||||||
@@ -27,7 +27,7 @@
|
|||||||
|
|
||||||
- A. Инфраструктура — модуль состояния, функция перевода с фолбэком и интерполяцией, словари-скелеты со всеми секциями (заполнены header и categories как пилот), переключатель RU/EN в шапке, реактивный lang. Тесты: фолбэк на ru при отсутствии ключа в en, интерполяция, персист локали.
|
- A. Инфраструктура — модуль состояния, функция перевода с фолбэком и интерполяцией, словари-скелеты со всеми секциями (заполнены header и categories как пилот), переключатель RU/EN в шапке, реактивный lang. Тесты: фолбэк на ru при отсутствии ключа в en, интерполяция, персист локали.
|
||||||
- B. Каркас интерфейса — перенос всех хардкод-строк компонентов и маршрутов в словари: layout (шапка, футер), главная (герой, «вернуть последний», title), каталог, карточки источника/результата/параметров, текстовый вход и результат, «Показать маску», DropZone/DropOverlay, кнопка скачивания (busyText), InfoPanel плюс форматирование чисел через тег локали вместо жёсткого ru-RU, ToolPage (легенды, «Шаг n», ошибки шага), ChainToolBlock, placeholder и пустой результат поиска, страницы инструмента (title, 404). Обёрточные сообщения исполнителя («Ошибка исполнения в воркере») — тоже здесь.
|
- B. Каркас интерфейса — перенос всех хардкод-строк компонентов и маршрутов в словари: layout (шапка, футер), главная (герой, «вернуть последний», title), каталог, карточки источника/результата/параметров, текстовый вход и результат, «Показать маску», DropZone/DropOverlay, кнопка скачивания (busyText), InfoPanel плюс форматирование чисел через тег локали вместо жёсткого ru-RU, ToolPage (легенды, «Шаг n», ошибки шага), ChainToolBlock, placeholder и пустой результат поиска, страницы инструмента (title, 404). Обёрточные сообщения исполнителя («Ошибка исполнения в воркере») — тоже здесь.
|
||||||
- C. Контент реестра — 69 инструментов: title/description/labels/options переезжают в tools-секцию обоих словарей; компоненты получают строки через хелперы toolTitle/paramLabel. Тест целостности: у каждого инструмента в каждом словаре непустые title/description, полный набор param-ключей и отсутствие лишних ключей.
|
- C. Контент реестра — переводы 69 инструментов (title/description/labels/options) в tools-секции словаря en; компоненты получают строки через хелперы toolTitle/toolDescription/paramLabel/optionLabel с фолбэком на реестр. Тест целостности: у каждого инструмента есть непустые перевод title/description, полный набор param-ключей и подписи всех опций select; лишние ключи словарей отсутствуют.
|
||||||
- D. Поиск по любому языку — нормализация запроса и полей: нижний регистр, ё→е, снятие диакритики через NFD; скоринг против активной и базовой локали сразу, лучший балл побеждает; сортировка совпадений через Intl.Collator активной локали. Тесты нормализации: ё/е, диакритика, английский запрос при русской локали и наоборот.
|
- D. Поиск по любому языку — нормализация запроса и полей: нижний регистр, ё→е, снятие диакритики через NFD; скоринг против активной и базовой локали сразу, лучший балл побеждает; сортировка совпадений через Intl.Collator активной локали. Тесты нормализации: ё/е, диакритика, английский запрос при русской локали и наоборот.
|
||||||
- E. Ошибки на кодах — около 40 мест throw в core, registry, pipeline заменяются на коды; исполнитель переводит код в текст активной локали, неизвестные сообщения показываются как есть. Пять тестов, матчащих русские подстроки ошибок (executor, pipeline, registry, io, alpha), переходят на коды — это упрощает ассерты. Русские describe/it в тестах не трогаем.
|
- E. Ошибки на кодах — около 40 мест throw в core, registry, pipeline заменяются на коды; исполнитель переводит код в текст активной локали, неизвестные сообщения показываются как есть. Пять тестов, матчащих русские подстроки ошибок (executor, pipeline, registry, io, alpha), переходят на коды — это упрощает ассерты. Русские describe/it в тестах не трогаем.
|
||||||
- F. Итог — полный прогон, смоук чеклист §6, архивация плана.
|
- F. Итог — полный прогон, смоук чеклист §6, архивация плана.
|
||||||
+1
-19
@@ -1,21 +1,3 @@
|
|||||||
# Backlog
|
# Backlog
|
||||||
|
|
||||||
## 1. Несколько языков — оценка: L, разбита на подзадачи
|
(пусто — выполненные волны лежат в docs/archive/; кандидат на следующую волну: текст и водяные знаки — add-text, watermark, штампы, см. roadmap фаза 1.5 и план wave3 §7.)
|
||||||
|
|
||||||
### 1a. Инфраструктура i18n — M
|
|
||||||
|
|
||||||
Модуль словарей (`locales/ru.ts`, `locales/en.ts`), функция перевода с фолбэком на базовый язык, состояние активной локали (localStorage), переключатель в шапке, обновление `<html lang>`.
|
|
||||||
|
|
||||||
### 1b. Перевод каркаса интерфейса — S/M
|
|
||||||
|
|
||||||
Шапка, кнопки, подписи полей и состояний («Результат появится здесь», тексты ошибок исполнителя), главная-герой, страница каталога.
|
|
||||||
|
|
||||||
### 1c. Перевод контента реестра — L по объёму, механическая
|
|
||||||
|
|
||||||
Вынести title/description инструментов и label/options параметров в словари локалей с фолбэком. Объём: ~51 инструмент плюс параметры — основной трудозатратный кусок волны.
|
|
||||||
|
|
||||||
### 1d. Поиск по любому языку — M
|
|
||||||
|
|
||||||
Матчинг запроса против названий/описаний во всех поддерживаемых локалях сразу (не только активной); нормализация: нижний регистр, ё→е, снятие диакритики (NFD). Скоринг существующий сохраняется, лучшая локаль даёт балл.
|
|
||||||
|
|
||||||
Транслитерация ru↔en наизусть — не входит, отдельно при необходимости.
|
|
||||||
|
|||||||
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="ru">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
|||||||
@@ -1 +1,23 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" width="107" height="128" viewBox="0 0 107 128"><title>svelte-logo</title><path d="M94.157 22.819c-10.4-14.885-30.94-19.297-45.792-9.835L22.282 29.608A29.92 29.92 0 0 0 8.764 49.65a31.5 31.5 0 0 0 3.108 20.231 30 30 0 0 0-4.477 11.183 31.9 31.9 0 0 0 5.448 24.116c10.402 14.887 30.942 19.297 45.791 9.835l26.083-16.624A29.92 29.92 0 0 0 98.235 78.35a31.53 31.53 0 0 0-3.105-20.232 30 30 0 0 0 4.474-11.182 31.88 31.88 0 0 0-5.447-24.116" style="fill:#ff3e00"/><path d="M45.817 106.582a20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.503 18 18 0 0 1 .624-2.435l.49-1.498 1.337.981a33.6 33.6 0 0 0 10.203 5.098l.97.294-.09.968a5.85 5.85 0 0 0 1.052 3.878 6.24 6.24 0 0 0 6.695 2.485 5.8 5.8 0 0 0 1.603-.704L69.27 76.28a5.43 5.43 0 0 0 2.45-3.631 5.8 5.8 0 0 0-.987-4.371 6.24 6.24 0 0 0-6.698-2.487 5.7 5.7 0 0 0-1.6.704l-9.953 6.345a19 19 0 0 1-5.296 2.326 20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.502 17.99 17.99 0 0 1 8.13-12.052l26.081-16.623a19 19 0 0 1 5.3-2.329 20.72 20.72 0 0 1 22.237 8.243 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-.624 2.435l-.49 1.498-1.337-.98a33.6 33.6 0 0 0-10.203-5.1l-.97-.294.09-.968a5.86 5.86 0 0 0-1.052-3.878 6.24 6.24 0 0 0-6.696-2.485 5.8 5.8 0 0 0-1.602.704L37.73 51.72a5.42 5.42 0 0 0-2.449 3.63 5.79 5.79 0 0 0 .986 4.372 6.24 6.24 0 0 0 6.698 2.486 5.8 5.8 0 0 0 1.602-.704l9.952-6.342a19 19 0 0 1 5.295-2.328 20.72 20.72 0 0 1 22.237 8.242 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-8.13 12.053l-26.081 16.622a19 19 0 0 1-5.3 2.328" style="fill:#fff"/></svg>
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||||
|
<title>easy-png-tools</title>
|
||||||
|
<rect width="32" height="32" rx="7" fill="#2563eb"/>
|
||||||
|
<rect x="5" y="5" width="22" height="22" rx="4" fill="#ffffff"/>
|
||||||
|
<clipPath id="pic">
|
||||||
|
<rect x="8" y="8" width="16" height="16" rx="2"/>
|
||||||
|
</clipPath>
|
||||||
|
<g clip-path="url(#pic)">
|
||||||
|
<rect x="8" y="8" width="16" height="16" fill="#eff6ff"/>
|
||||||
|
<g fill="#bfdbfe">
|
||||||
|
<rect x="12" y="8" width="4" height="4"/>
|
||||||
|
<rect x="20" y="8" width="4" height="4"/>
|
||||||
|
<rect x="8" y="12" width="4" height="4"/>
|
||||||
|
<rect x="16" y="12" width="4" height="4"/>
|
||||||
|
<rect x="12" y="16" width="4" height="4"/>
|
||||||
|
<rect x="20" y="16" width="4" height="4"/>
|
||||||
|
<rect x="8" y="20" width="4" height="4"/>
|
||||||
|
<rect x="16" y="20" width="4" height="4"/>
|
||||||
|
</g>
|
||||||
|
<circle cx="11.6" cy="12.2" r="2.1" fill="#2563eb"/>
|
||||||
|
<path d="M7 25 L13 15.2 L16.6 20.4 L19.2 17 L25.4 25 Z" fill="#2563eb"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 920 B |
@@ -1,11 +1,11 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { Snippet } from 'svelte';
|
import type { Snippet } from 'svelte';
|
||||||
import { isSupportedImage, unsupportedImageMessage } from '$lib/core/io';
|
import { isSupportedImage, unsupportedImageError } from '$lib/core/io';
|
||||||
import { t } from '$lib/i18n/t';
|
import { t } from '$lib/i18n/t';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
onFile: (file: File) => void;
|
onFile: (file: File) => void;
|
||||||
onError?: (message: string) => void;
|
onError?: (e: unknown) => void;
|
||||||
label?: string;
|
label?: string;
|
||||||
children: Snippet;
|
children: Snippet;
|
||||||
}
|
}
|
||||||
@@ -42,7 +42,7 @@
|
|||||||
const file = event.dataTransfer?.files[0];
|
const file = event.dataTransfer?.files[0];
|
||||||
if (!file) return;
|
if (!file) return;
|
||||||
if (!isSupportedImage(file)) {
|
if (!isSupportedImage(file)) {
|
||||||
onError?.(unsupportedImageMessage(file));
|
onError?.(unsupportedImageError(file));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
onFile(file);
|
onFile(file);
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { ACCEPTED_IMAGE_TYPES, isSupportedImage, unsupportedImageMessage } from '$lib/core/io';
|
import { ACCEPTED_IMAGE_TYPES, isSupportedImage, unsupportedImageError } from '$lib/core/io';
|
||||||
import { t } from '$lib/i18n/t';
|
import { t } from '$lib/i18n/t';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
onFile: (file: File) => void;
|
onFile: (file: File) => void;
|
||||||
onError?: (message: string) => void;
|
onError?: (e: unknown) => void;
|
||||||
label?: string;
|
label?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -33,7 +33,7 @@
|
|||||||
function accept(file: File | undefined | null) {
|
function accept(file: File | undefined | null) {
|
||||||
if (!file) return;
|
if (!file) return;
|
||||||
if (!isSupportedImage(file)) {
|
if (!isSupportedImage(file)) {
|
||||||
onError?.(unsupportedImageMessage(file));
|
onError?.(unsupportedImageError(file));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
onFile(file);
|
onFile(file);
|
||||||
|
|||||||
@@ -4,10 +4,12 @@
|
|||||||
import SelectField from './ui/SelectField.svelte';
|
import SelectField from './ui/SelectField.svelte';
|
||||||
import SliderField from './ui/SliderField.svelte';
|
import SliderField from './ui/SliderField.svelte';
|
||||||
import TextField from './ui/TextField.svelte';
|
import TextField from './ui/TextField.svelte';
|
||||||
import type { ParamDef } from '$lib/registry';
|
import type { ParamDef, ToolEntry } from '$lib/registry';
|
||||||
|
import { optionLabel, paramLabel } from '$lib/i18n/tool-strings';
|
||||||
import { t } from '$lib/i18n/t';
|
import { t } from '$lib/i18n/t';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
|
tool: ToolEntry;
|
||||||
params: ParamDef[];
|
params: ParamDef[];
|
||||||
values: Record<string, any>;
|
values: Record<string, any>;
|
||||||
pipetteTargetId?: string | null;
|
pipetteTargetId?: string | null;
|
||||||
@@ -17,6 +19,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
let {
|
let {
|
||||||
|
tool,
|
||||||
params,
|
params,
|
||||||
values = $bindable(),
|
values = $bindable(),
|
||||||
pipetteTargetId = null,
|
pipetteTargetId = null,
|
||||||
@@ -33,11 +36,11 @@
|
|||||||
{#each params as param (param.id)}
|
{#each params as param (param.id)}
|
||||||
<div class="field">
|
<div class="field">
|
||||||
{#if param.type === 'checkbox'}
|
{#if param.type === 'checkbox'}
|
||||||
<CheckboxField id={param.id} label={param.label} bind:checked={values[param.id]} />
|
<CheckboxField id={param.id} label={paramLabel(tool, param)} bind:checked={values[param.id]} />
|
||||||
{:else if param.type === 'number'}
|
{:else if param.type === 'number'}
|
||||||
<TextField
|
<TextField
|
||||||
id={param.id}
|
id={param.id}
|
||||||
label={param.label}
|
label={paramLabel(tool, param)}
|
||||||
type="number"
|
type="number"
|
||||||
min={param.min}
|
min={param.min}
|
||||||
max={param.max}
|
max={param.max}
|
||||||
@@ -47,7 +50,7 @@
|
|||||||
{:else if param.type === 'slider'}
|
{:else if param.type === 'slider'}
|
||||||
<SliderField
|
<SliderField
|
||||||
id={param.id}
|
id={param.id}
|
||||||
label={param.label}
|
label={paramLabel(tool, param)}
|
||||||
min={param.min}
|
min={param.min}
|
||||||
max={param.max}
|
max={param.max}
|
||||||
step={param.step}
|
step={param.step}
|
||||||
@@ -57,14 +60,17 @@
|
|||||||
{:else if param.type === 'select'}
|
{:else if param.type === 'select'}
|
||||||
<SelectField
|
<SelectField
|
||||||
id={param.id}
|
id={param.id}
|
||||||
label={param.label}
|
label={paramLabel(tool, param)}
|
||||||
options={param.options}
|
options={param.options.map((o) => ({
|
||||||
|
value: o.value,
|
||||||
|
label: optionLabel(tool, param, o.value)
|
||||||
|
}))}
|
||||||
bind:value={values[param.id]}
|
bind:value={values[param.id]}
|
||||||
/>
|
/>
|
||||||
{:else if param.type === 'color'}
|
{:else if param.type === 'color'}
|
||||||
<ColorField
|
<ColorField
|
||||||
id={param.id}
|
id={param.id}
|
||||||
label={param.label}
|
label={paramLabel(tool, param)}
|
||||||
bind:value={values[param.id]}
|
bind:value={values[param.id]}
|
||||||
pipetteActive={pipetteTargetId === param.id}
|
pipetteActive={pipetteTargetId === param.id}
|
||||||
onPipetteToggle={() => onPipetteToggle?.(param.id)}
|
onPipetteToggle={() => onPipetteToggle?.(param.id)}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { imageInfo, type ImageInfo } from '$lib/core/analyze';
|
import { imageInfo, type ImageInfo } from '$lib/core/analyze';
|
||||||
import { decodeFile, isSupportedImage, unsupportedImageMessage } from '$lib/core/io';
|
import { ToolError } from '$lib/core/errors';
|
||||||
|
import { decodeFile, isSupportedImage, unsupportedImageError } from '$lib/core/io';
|
||||||
import type { PixelImage } from '$lib/core/types';
|
import type { PixelImage } from '$lib/core/types';
|
||||||
import { defaultParams, getTool, outputOf, sanitizeParams, type ToolEntry } from '$lib/registry';
|
import { defaultParams, getTool, outputOf, sanitizeParams, type ToolEntry } from '$lib/registry';
|
||||||
import { loadStoredSteps, newStepId, saveSteps, type PipelineStep } from '$lib/tools/pipeline';
|
import { loadStoredSteps, newStepId, saveSteps, type PipelineStep } from '$lib/tools/pipeline';
|
||||||
@@ -12,6 +13,7 @@
|
|||||||
import { createAutoRunner } from '$lib/tools/auto-run';
|
import { createAutoRunner } from '$lib/tools/auto-run';
|
||||||
import { executeStep } from '$lib/tools/executor';
|
import { executeStep } from '$lib/tools/executor';
|
||||||
import { t } from '$lib/i18n/t';
|
import { t } from '$lib/i18n/t';
|
||||||
|
import { toolDescription, toolTitle } from '$lib/i18n/tool-strings';
|
||||||
import ParamsCard from './tool/ParamsCard.svelte';
|
import ParamsCard from './tool/ParamsCard.svelte';
|
||||||
import ResultCard from './tool/ResultCard.svelte';
|
import ResultCard from './tool/ResultCard.svelte';
|
||||||
import SourceCard from './tool/SourceCard.svelte';
|
import SourceCard from './tool/SourceCard.svelte';
|
||||||
@@ -176,9 +178,8 @@
|
|||||||
try {
|
try {
|
||||||
current = await executeStep(stepTool, current, sanitizeParams(stepTool, step.values));
|
current = await executeStep(stepTool, current, sanitizeParams(stepTool, step.values));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const rawMsg = e instanceof Error ? e.message : String(e);
|
|
||||||
throw new Error(
|
throw new Error(
|
||||||
t('toolPage.stepError', { n: i + 1, title: stepTool.title, msg: t(rawMsg) })
|
t('toolPage.stepError', { n: i + 1, title: toolTitle(stepTool), msg: errorMessage(e) })
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (!runner.isCurrent(token)) return;
|
if (!runner.isCurrent(token)) return;
|
||||||
@@ -214,10 +215,14 @@
|
|||||||
saveSteps(filled);
|
saveSteps(filled);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function errorMessage(e: unknown): string {
|
||||||
|
if (e instanceof ToolError) return t(e.key, e.vars);
|
||||||
|
return t(e instanceof Error ? e.message : String(e));
|
||||||
|
}
|
||||||
|
|
||||||
function showError(e: unknown) {
|
function showError(e: unknown) {
|
||||||
status = source ? 'loaded' : 'idle';
|
status = source ? 'loaded' : 'idle';
|
||||||
const raw = e instanceof Error ? e.message : String(e);
|
errorText = errorMessage(e);
|
||||||
errorText = t(raw);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function reset() {
|
function reset() {
|
||||||
@@ -242,7 +247,7 @@
|
|||||||
if (file) {
|
if (file) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
if (!isSupportedImage(file)) {
|
if (!isSupportedImage(file)) {
|
||||||
errorText = unsupportedImageMessage(file);
|
errorText = errorMessage(unsupportedImageError(file));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
handleFile(file);
|
handleFile(file);
|
||||||
@@ -255,8 +260,8 @@
|
|||||||
<svelte:window onpaste={handlePaste} />
|
<svelte:window onpaste={handlePaste} />
|
||||||
|
|
||||||
<section>
|
<section>
|
||||||
<h1>{tool.title}</h1>
|
<h1>{toolTitle(tool)}</h1>
|
||||||
<p class="description text-muted">{tool.description}</p>
|
<p class="description text-muted">{toolDescription(tool)}</p>
|
||||||
|
|
||||||
{#if errorText}
|
{#if errorText}
|
||||||
<div class="error-banner" role="alert">{errorText}</div>
|
<div class="error-banner" role="alert">{errorText}</div>
|
||||||
@@ -273,7 +278,7 @@
|
|||||||
<SourceCard
|
<SourceCard
|
||||||
{source}
|
{source}
|
||||||
onFile={handleFile}
|
onFile={handleFile}
|
||||||
onError={(message) => (errorText = message)}
|
onError={(e) => (errorText = errorMessage(e))}
|
||||||
onReset={reset}
|
onReset={reset}
|
||||||
pipetteActive={!!pipetteTargetId}
|
pipetteActive={!!pipetteTargetId}
|
||||||
onPickColor={handlePickColor}
|
onPickColor={handlePickColor}
|
||||||
@@ -307,6 +312,7 @@
|
|||||||
<span class="edge-legend" aria-hidden="true">{t('toolPage.legendParams')}</span>
|
<span class="edge-legend" aria-hidden="true">{t('toolPage.legendParams')}</span>
|
||||||
</div>
|
</div>
|
||||||
<ParamsCard
|
<ParamsCard
|
||||||
|
tool={tool}
|
||||||
params={tool.params}
|
params={tool.params}
|
||||||
bind:values
|
bind:values
|
||||||
{pipetteTargetId}
|
{pipetteTargetId}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import { outputOf, sanitizeParams, type ToolEntry } from '$lib/registry';
|
import { outputOf, sanitizeParams, type ToolEntry } from '$lib/registry';
|
||||||
import type { PixelImage } from '$lib/core/types';
|
import type { PixelImage } from '$lib/core/types';
|
||||||
import { t } from '$lib/i18n/t';
|
import { t } from '$lib/i18n/t';
|
||||||
|
import { toolTitle } from '$lib/i18n/tool-strings';
|
||||||
import DownloadButton from '../DownloadButton.svelte';
|
import DownloadButton from '../DownloadButton.svelte';
|
||||||
import Button from '../ui/Button.svelte';
|
import Button from '../ui/Button.svelte';
|
||||||
import EmptyState from '../ui/EmptyState.svelte';
|
import EmptyState from '../ui/EmptyState.svelte';
|
||||||
@@ -48,7 +49,7 @@
|
|||||||
{#if StepIcon}
|
{#if StepIcon}
|
||||||
<span class="step-icon" aria-hidden="true"><StepIcon size={14} strokeWidth={2} /></span>
|
<span class="step-icon" aria-hidden="true"><StepIcon size={14} strokeWidth={2} /></span>
|
||||||
{/if}
|
{/if}
|
||||||
{t('chain.stepLabel', { n: index + 1, title: tool.title })}
|
{t('chain.stepLabel', { n: index + 1, title: toolTitle(tool) })}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="remove"
|
class="remove"
|
||||||
@@ -99,7 +100,7 @@
|
|||||||
<div class="params-sep">
|
<div class="params-sep">
|
||||||
<span class="edge-legend" aria-hidden="true">{t('chain.paramsLegend')}</span>
|
<span class="edge-legend" aria-hidden="true">{t('chain.paramsLegend')}</span>
|
||||||
</div>
|
</div>
|
||||||
<ParamsCard params={tool.params} bind:values />
|
<ParamsCard tool={tool} params={tool.params} bind:values />
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { isChainable, TOOLS } from '$lib/registry';
|
import { isChainable, TOOLS } from '$lib/registry';
|
||||||
|
import { LOCALE_TAGS } from '$lib/i18n/dict';
|
||||||
|
import { getLocale } from '$lib/i18n/locale.svelte';
|
||||||
|
import { normalizeForSearch, scoreDoc } from '$lib/i18n/matching';
|
||||||
import { t } from '$lib/i18n/t';
|
import { t } from '$lib/i18n/t';
|
||||||
|
import { toolDescription, toolSearchDoc, toolTitle } from '$lib/i18n/tool-strings';
|
||||||
import ToolCard from './ToolCard.svelte';
|
import ToolCard from './ToolCard.svelte';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -23,51 +27,27 @@
|
|||||||
score: number;
|
score: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
function score(tool: (typeof TOOLS)[number], q: string): number | null {
|
|
||||||
if (q.length === 0) return 1;
|
|
||||||
const title = tool.title.toLowerCase();
|
|
||||||
const id = tool.id.toLowerCase();
|
|
||||||
const description = tool.description.toLowerCase();
|
|
||||||
if (title.startsWith(q)) return 100;
|
|
||||||
const inTitle = title.indexOf(q);
|
|
||||||
if (inTitle >= 0) return 80 - Math.min(inTitle, 40);
|
|
||||||
const inId = id.indexOf(q);
|
|
||||||
if (inId >= 0) return 60 - Math.min(inId, 40);
|
|
||||||
if (description.includes(q)) return 30;
|
|
||||||
let pos = 0;
|
|
||||||
let subScore = 20;
|
|
||||||
for (const ch of q) {
|
|
||||||
pos = title.indexOf(ch, pos);
|
|
||||||
if (pos < 0) {
|
|
||||||
subScore = -1;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
subScore -= 1;
|
|
||||||
pos += 1;
|
|
||||||
}
|
|
||||||
return subScore >= 0 ? subScore : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const matches = $derived.by(() => {
|
const matches = $derived.by(() => {
|
||||||
const q = query.trim().toLowerCase();
|
const q = normalizeForSearch(query.trim());
|
||||||
const found: Match[] = [];
|
const found: Match[] = [];
|
||||||
for (const tool of candidates) {
|
for (const tool of candidates) {
|
||||||
const s = score(tool, q);
|
const s = scoreDoc(toolSearchDoc(tool), q);
|
||||||
if (s !== null && s > 0) {
|
if (s !== null && s > 0) {
|
||||||
found.push({
|
found.push({
|
||||||
id: tool.id,
|
id: tool.id,
|
||||||
title: tool.title,
|
title: toolTitle(tool),
|
||||||
description: tool.description,
|
description: toolDescription(tool),
|
||||||
popularity: tool.popularity ?? 50,
|
popularity: tool.popularity ?? 50,
|
||||||
score: s
|
score: s
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
const collator = new Intl.Collator(LOCALE_TAGS[getLocale()]);
|
||||||
found.sort(
|
found.sort(
|
||||||
(a, b) =>
|
(a, b) =>
|
||||||
b.score - a.score ||
|
b.score - a.score ||
|
||||||
b.popularity - a.popularity ||
|
b.popularity - a.popularity ||
|
||||||
a.title.localeCompare(b.title)
|
collator.compare(a.title, b.title)
|
||||||
);
|
);
|
||||||
return found.slice(0, 12);
|
return found.slice(0, 12);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import ParamForm from '../ParamForm.svelte';
|
import ParamForm from '../ParamForm.svelte';
|
||||||
import type { ParamDef } from '$lib/registry';
|
import type { ParamDef, ToolEntry } from '$lib/registry';
|
||||||
import { t } from '$lib/i18n/t';
|
import { t } from '$lib/i18n/t';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
|
tool: ToolEntry;
|
||||||
params: ParamDef[];
|
params: ParamDef[];
|
||||||
values: Record<string, any>;
|
values: Record<string, any>;
|
||||||
pipetteTargetId?: string | null;
|
pipetteTargetId?: string | null;
|
||||||
@@ -13,6 +14,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
let {
|
let {
|
||||||
|
tool,
|
||||||
params,
|
params,
|
||||||
values = $bindable(),
|
values = $bindable(),
|
||||||
pipetteTargetId = null,
|
pipetteTargetId = null,
|
||||||
@@ -25,6 +27,7 @@
|
|||||||
<div class="root">
|
<div class="root">
|
||||||
{#if params.length > 0 || hasMask}
|
{#if params.length > 0 || hasMask}
|
||||||
<ParamForm
|
<ParamForm
|
||||||
|
{tool}
|
||||||
{params}
|
{params}
|
||||||
bind:values
|
bind:values
|
||||||
{pipetteTargetId}
|
{pipetteTargetId}
|
||||||
|
|||||||
@@ -64,7 +64,7 @@
|
|||||||
{:else if tool.resultType === 'text'}
|
{:else if tool.resultType === 'text'}
|
||||||
<div class="media">
|
<div class="media">
|
||||||
{#if textResult !== null}
|
{#if textResult !== null}
|
||||||
<TextResult text={textResult} filename={tool.id} />
|
<TextResult text={textResult} filename={tool.id} toolId={tool.id} />
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
interface Props {
|
interface Props {
|
||||||
source: PixelImage | null;
|
source: PixelImage | null;
|
||||||
onFile: (file: File) => void;
|
onFile: (file: File) => void;
|
||||||
onError: (message: string) => void;
|
onError: (e: unknown) => void;
|
||||||
onReset: () => void;
|
onReset: () => void;
|
||||||
pipetteActive?: boolean;
|
pipetteActive?: boolean;
|
||||||
onPickColor?: (hex: string) => void;
|
onPickColor?: (hex: string) => void;
|
||||||
|
|||||||
@@ -1,29 +1,35 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { downloadBlob } from '$lib/core/io';
|
import { downloadBlob } from '$lib/core/io';
|
||||||
import { t } from '$lib/i18n/t';
|
import { t } from '$lib/i18n/t';
|
||||||
|
import { getMergedDict } from '$lib/i18n/locale.svelte';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
text: string;
|
text: string;
|
||||||
filename: string;
|
filename: string;
|
||||||
|
toolId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
let { text, filename }: Props = $props();
|
let { text, filename, toolId }: Props = $props();
|
||||||
|
|
||||||
|
const shown = $derived(
|
||||||
|
toolId ? (getMergedDict().tools[toolId]?.results?.[text] ?? text) : text
|
||||||
|
);
|
||||||
|
|
||||||
let copied = $state(false);
|
let copied = $state(false);
|
||||||
|
|
||||||
async function copy() {
|
async function copy() {
|
||||||
await navigator.clipboard.writeText(text);
|
await navigator.clipboard.writeText(shown);
|
||||||
copied = true;
|
copied = true;
|
||||||
setTimeout(() => (copied = false), 1500);
|
setTimeout(() => (copied = false), 1500);
|
||||||
}
|
}
|
||||||
|
|
||||||
function download() {
|
function download() {
|
||||||
downloadBlob(new Blob([text], { type: 'text/plain' }), `${filename}.txt`);
|
downloadBlob(new Blob([shown], { type: 'text/plain' }), `${filename}.txt`);
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="panel text-result">
|
<div class="panel text-result">
|
||||||
<textarea class="output" rows="10" readonly value={text} aria-label={t('textResult.outputAria')}></textarea>
|
<textarea class="output" rows="10" readonly value={shown} aria-label={t('textResult.outputAria')}></textarea>
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<button type="button" class="secondary" onclick={copy}>
|
<button type="button" class="secondary" onclick={copy}>
|
||||||
{copied ? t('textResult.copied') : t('textResult.copy')}
|
{copied ? t('textResult.copied') : t('textResult.copy')}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { parseHex } from './alpha';
|
import { parseHex } from './alpha';
|
||||||
|
import { ToolError } from './errors';
|
||||||
import { clonePixelImage, createPixelImage, type PixelImage } from './types';
|
import { clonePixelImage, createPixelImage, type PixelImage } from './types';
|
||||||
import { sampleBilinear } from './geometry';
|
import { sampleBilinear } from './geometry';
|
||||||
|
|
||||||
@@ -7,7 +8,7 @@ export type AffineMatrix = [number, number, number, number, number, number];
|
|||||||
export function invertAffine([a, b, c, d, e, f]: AffineMatrix): AffineMatrix {
|
export function invertAffine([a, b, c, d, e, f]: AffineMatrix): AffineMatrix {
|
||||||
const det = a * d - b * c;
|
const det = a * d - b * c;
|
||||||
if (Math.abs(det) < 1e-12) {
|
if (Math.abs(det) < 1e-12) {
|
||||||
throw new Error('Вырожденная матрица трансформации');
|
throw new ToolError('errors.badTransform');
|
||||||
}
|
}
|
||||||
const ia = d / det;
|
const ia = d / det;
|
||||||
const ib = -b / det;
|
const ib = -b / det;
|
||||||
@@ -91,7 +92,7 @@ export function skewImage(img: PixelImage, degX: number, degY: number): PixelIma
|
|||||||
const kx = Math.tan((degX * Math.PI) / 180);
|
const kx = Math.tan((degX * Math.PI) / 180);
|
||||||
const ky = Math.tan((degY * Math.PI) / 180);
|
const ky = Math.tan((degY * Math.PI) / 180);
|
||||||
if (!Number.isFinite(kx) || !Number.isFinite(ky)) {
|
if (!Number.isFinite(kx) || !Number.isFinite(ky)) {
|
||||||
throw new Error('Углы наклона не могут быть 90° или -90°');
|
throw new ToolError('errors.skewAngle');
|
||||||
}
|
}
|
||||||
return centeredTransform(img, [1, ky, kx, 1, 0, 0]);
|
return centeredTransform(img, [1, ky, kx, 1, 0, 0]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -181,6 +181,6 @@ describe('parseHex', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it.each(['zzz', '12345', '##ff', ''])('бросает ошибку на "%s"', (bad) => {
|
it.each(['zzz', '12345', '##ff', ''])('бросает ошибку на "%s"', (bad) => {
|
||||||
expect(() => parseHex(bad)).toThrow(/Некорректный HEX/);
|
expect(() => parseHex(bad)).toThrow(/errors\.badHex/);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { createPixelImage, type PixelImage } from './types';
|
import { createPixelImage, type PixelImage } from './types';
|
||||||
|
import { ToolError } from './errors';
|
||||||
|
|
||||||
const MAX_COLOR_DISTANCE = Math.sqrt(3 * 255 * 255);
|
const MAX_COLOR_DISTANCE = Math.sqrt(3 * 255 * 255);
|
||||||
|
|
||||||
@@ -120,7 +121,7 @@ export function flattenOntoColor(img: PixelImage, hex: string): PixelImage {
|
|||||||
export function parseHex(hex: string): [number, number, number] {
|
export function parseHex(hex: string): [number, number, number] {
|
||||||
const match = /^#?([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(hex.trim());
|
const match = /^#?([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(hex.trim());
|
||||||
if (!match) {
|
if (!match) {
|
||||||
throw new Error(`Некорректный HEX-цвет: "${hex}"`);
|
throw new ToolError('errors.badHex', { value: hex });
|
||||||
}
|
}
|
||||||
const digits = match[1];
|
const digits = match[1];
|
||||||
if (digits.length === 3) {
|
if (digits.length === 3) {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { clonePixelImage, createPixelImage, type PixelImage } from './types';
|
import { clonePixelImage, createPixelImage, type PixelImage } from './types';
|
||||||
|
import { ToolError } from './errors';
|
||||||
|
|
||||||
export type RgbChannel = 'red' | 'green' | 'blue';
|
export type RgbChannel = 'red' | 'green' | 'blue';
|
||||||
|
|
||||||
@@ -163,7 +164,7 @@ export function twoColors(
|
|||||||
function parseColor(hex: string): [number, number, number] {
|
function parseColor(hex: string): [number, number, number] {
|
||||||
const match = /^#([0-9a-f]{6})$/i.exec(hex.trim());
|
const match = /^#([0-9a-f]{6})$/i.exec(hex.trim());
|
||||||
if (!match) {
|
if (!match) {
|
||||||
throw new Error(`Некорректный HEX-цвет: "${hex}"`);
|
throw new ToolError('errors.badHex', { value: hex });
|
||||||
}
|
}
|
||||||
const digits = match[1];
|
const digits = match[1];
|
||||||
return [
|
return [
|
||||||
@@ -289,7 +290,7 @@ export function tint(
|
|||||||
): PixelImage {
|
): PixelImage {
|
||||||
const s = clamp(strengthPercent, 0, 100) / 100;
|
const s = clamp(strengthPercent, 0, 100) / 100;
|
||||||
const match = /^#([0-9a-f]{6})$/i.exec(colorHex.trim());
|
const match = /^#([0-9a-f]{6})$/i.exec(colorHex.trim());
|
||||||
if (!match) throw new Error(`Некорректный HEX-цвет: "${colorHex}"`);
|
if (!match) throw new ToolError('errors.badHex', { value: colorHex });
|
||||||
const d = match[1];
|
const d = match[1];
|
||||||
const tr = parseInt(d.slice(0, 2), 16) / 255;
|
const tr = parseInt(d.slice(0, 2), 16) / 255;
|
||||||
const tg = parseInt(d.slice(2, 4), 16) / 255;
|
const tg = parseInt(d.slice(2, 4), 16) / 255;
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { clonePixelImage, createPixelImage, type PixelImage } from './types';
|
import { clonePixelImage, createPixelImage, type PixelImage } from './types';
|
||||||
|
import { ToolError } from './errors';
|
||||||
|
|
||||||
type Plane = Float64Array;
|
type Plane = Float64Array;
|
||||||
|
|
||||||
@@ -8,10 +9,10 @@ export function convolve(
|
|||||||
size: number
|
size: number
|
||||||
): PixelImage {
|
): PixelImage {
|
||||||
if (!Number.isInteger(size) || size < 1 || size % 2 === 0) {
|
if (!Number.isInteger(size) || size < 1 || size % 2 === 0) {
|
||||||
throw new Error('Размер ядра должен быть нечётным положительным числом');
|
throw new ToolError('errors.radiusInt');
|
||||||
}
|
}
|
||||||
if (kernel.length !== size * size) {
|
if (kernel.length !== size * size) {
|
||||||
throw new Error('Длина ядра не совпадает с его размером');
|
throw new ToolError('errors.kernelSize');
|
||||||
}
|
}
|
||||||
const half = Math.floor(size / 2);
|
const half = Math.floor(size / 2);
|
||||||
const out = createPixelImage(img.width, img.height);
|
const out = createPixelImage(img.width, img.height);
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
export type ErrorVars = Record<string, string | number>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ошибка с стабильным ключом перевода вместо готового текста.
|
||||||
|
* Ядро бросает только её; человекочитаемый текст подставляет слой UI
|
||||||
|
* по секции errors активной локали.
|
||||||
|
*/
|
||||||
|
export class ToolError extends Error {
|
||||||
|
readonly key: string;
|
||||||
|
readonly vars?: ErrorVars;
|
||||||
|
|
||||||
|
constructor(key: string, vars?: ErrorVars) {
|
||||||
|
super(key);
|
||||||
|
this.name = 'ToolError';
|
||||||
|
this.key = key;
|
||||||
|
this.vars = vars;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { PixelImage } from './types';
|
import type { PixelImage } from './types';
|
||||||
|
import { ToolError } from './errors';
|
||||||
|
|
||||||
export function solidImage(
|
export function solidImage(
|
||||||
width: number,
|
width: number,
|
||||||
@@ -6,7 +7,7 @@ export function solidImage(
|
|||||||
rgba: [number, number, number, number]
|
rgba: [number, number, number, number]
|
||||||
): PixelImage {
|
): PixelImage {
|
||||||
if (!Number.isInteger(width) || !Number.isInteger(height) || width < 1 || height < 1) {
|
if (!Number.isInteger(width) || !Number.isInteger(height) || width < 1 || height < 1) {
|
||||||
throw new Error('Размеры должны быть целыми числами >= 1');
|
throw new ToolError('errors.sizeInt');
|
||||||
}
|
}
|
||||||
const data = new Uint8ClampedArray(width * height * 4);
|
const data = new Uint8ClampedArray(width * height * 4);
|
||||||
for (let i = 0; i < data.length; i += 4) {
|
for (let i = 0; i < data.length; i += 4) {
|
||||||
@@ -20,7 +21,7 @@ export function solidImage(
|
|||||||
|
|
||||||
export function noiseImage(width: number, height: number, seed: number): PixelImage {
|
export function noiseImage(width: number, height: number, seed: number): PixelImage {
|
||||||
if (!Number.isInteger(width) || !Number.isInteger(height) || width < 1 || height < 1) {
|
if (!Number.isInteger(width) || !Number.isInteger(height) || width < 1 || height < 1) {
|
||||||
throw new Error('Размеры должны быть целыми числами >= 1');
|
throw new ToolError('errors.sizeInt');
|
||||||
}
|
}
|
||||||
const random = mulberry32(seed);
|
const random = mulberry32(seed);
|
||||||
const data = new Uint8ClampedArray(width * height * 4);
|
const data = new Uint8ClampedArray(width * height * 4);
|
||||||
@@ -41,7 +42,7 @@ export function gradientImage(
|
|||||||
direction: 'horizontal' | 'vertical'
|
direction: 'horizontal' | 'vertical'
|
||||||
): PixelImage {
|
): PixelImage {
|
||||||
if (!Number.isInteger(width) || !Number.isInteger(height) || width < 1 || height < 1) {
|
if (!Number.isInteger(width) || !Number.isInteger(height) || width < 1 || height < 1) {
|
||||||
throw new Error('Размеры должны быть целыми числами >= 1');
|
throw new ToolError('errors.sizeInt');
|
||||||
}
|
}
|
||||||
const out: PixelImage = {
|
const out: PixelImage = {
|
||||||
width,
|
width,
|
||||||
|
|||||||
@@ -121,8 +121,8 @@ describe('crop', () => {
|
|||||||
expect([...out.data]).toEqual([1, 1, 1, 1]);
|
expect([...out.data]).toEqual([1, 1, 1, 1]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('бросает RangeError для области вне изображения', () => {
|
it('бросает ToolError для области вне изображения', () => {
|
||||||
expect(() => crop(grid(), 5, 5, 2, 2)).toThrow(RangeError);
|
expect(() => crop(grid(), 5, 5, 2, 2)).toThrow(/errors\.cropBounds/);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -207,9 +207,9 @@ describe('resize', () => {
|
|||||||
expect(red.slice(4, 8)).toEqual([50, 72, 117, 139]);
|
expect(red.slice(4, 8)).toEqual([50, 72, 117, 139]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('бросает RangeError на некорректные размеры', () => {
|
it('бросает ToolError на некорректные размеры', () => {
|
||||||
const img = twoByTwo();
|
const img = twoByTwo();
|
||||||
expect(() => resize(img, 0, 10)).toThrow(RangeError);
|
expect(() => resize(img, 0, 10)).toThrow(/errors\.sizeInt/);
|
||||||
expect(() => resize(img, 10.5, 10)).toThrow(RangeError);
|
expect(() => resize(img, 10.5, 10)).toThrow(/errors\.sizeInt/);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { parseHex } from './alpha';
|
import { parseHex } from './alpha';
|
||||||
|
import { ToolError } from './errors';
|
||||||
import { clonePixelImage, createPixelImage, type PixelImage } from './types';
|
import { clonePixelImage, createPixelImage, type PixelImage } from './types';
|
||||||
|
|
||||||
export function expandCanvas(
|
export function expandCanvas(
|
||||||
@@ -128,7 +129,7 @@ export function crop(
|
|||||||
const w = ex - sx;
|
const w = ex - sx;
|
||||||
const h = ey - sy;
|
const h = ey - sy;
|
||||||
if (w <= 0 || h <= 0) {
|
if (w <= 0 || h <= 0) {
|
||||||
throw new RangeError('Область обрезки пуста: она целиком вне изображения');
|
throw new ToolError('errors.cropBounds');
|
||||||
}
|
}
|
||||||
const out = createPixelImage(w, h);
|
const out = createPixelImage(w, h);
|
||||||
for (let row = 0; row < h; row++) {
|
for (let row = 0; row < h; row++) {
|
||||||
@@ -140,7 +141,7 @@ export function crop(
|
|||||||
|
|
||||||
export function resize(img: PixelImage, width: number, height: number): PixelImage {
|
export function resize(img: PixelImage, width: number, height: number): PixelImage {
|
||||||
if (!Number.isInteger(width) || !Number.isInteger(height) || width < 1 || height < 1) {
|
if (!Number.isInteger(width) || !Number.isInteger(height) || width < 1 || height < 1) {
|
||||||
throw new RangeError('Размеры должны быть целыми числами >= 1');
|
throw new ToolError('errors.sizeInt');
|
||||||
}
|
}
|
||||||
const out = createPixelImage(width, height);
|
const out = createPixelImage(width, height);
|
||||||
const xr = img.width / width;
|
const xr = img.width / width;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import { isSupportedImage, unsupportedImageMessage } from './io';
|
import { isSupportedImage, unsupportedImageError } from './io';
|
||||||
|
|
||||||
describe('isSupportedImage', () => {
|
describe('isSupportedImage', () => {
|
||||||
it.each(['image/png', 'image/jpeg', 'image/webp', 'image/gif', 'image/bmp', 'image/x-icon'])(
|
it.each(['image/png', 'image/jpeg', 'image/webp', 'image/gif', 'image/bmp', 'image/x-icon'])(
|
||||||
@@ -18,14 +18,15 @@ describe('isSupportedImage', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('unsupportedImageMessage', () => {
|
describe('unsupportedImageError', () => {
|
||||||
it('упоминает тип файла и поддерживаемые форматы', () => {
|
it('ключ ошибки и тип файла в vars', () => {
|
||||||
const message = unsupportedImageMessage(new File([], 'a.txt', { type: 'text/plain' }));
|
const err = unsupportedImageError(new File([], 'a.txt', { type: 'text/plain' }));
|
||||||
expect(message).toContain('text/plain');
|
expect(err.key).toBe('errors.unsupportedFile');
|
||||||
expect(message).toContain('PNG');
|
expect(err.vars?.type).toBe('text/plain');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('сообщает про неизвестный тип, когда он пуст', () => {
|
it('пустой тип передаётся как unknown', () => {
|
||||||
expect(unsupportedImageMessage(new File([], 'x'))).toContain('неизвестный');
|
const err = unsupportedImageError(new File([], 'x'));
|
||||||
|
expect(err.vars?.type).toBe('unknown');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+15
-11
@@ -1,4 +1,5 @@
|
|||||||
import { encodeBmpBytes } from './bmp';
|
import { encodeBmpBytes } from './bmp';
|
||||||
|
import { ToolError } from './errors';
|
||||||
import { type PixelImage } from './types';
|
import { type PixelImage } from './types';
|
||||||
|
|
||||||
export type OutputMime = 'image/png' | 'image/jpeg' | 'image/webp' | 'image/bmp';
|
export type OutputMime = 'image/png' | 'image/jpeg' | 'image/webp' | 'image/bmp';
|
||||||
@@ -12,8 +13,8 @@ export function isSupportedImage(file: File): boolean {
|
|||||||
return SUPPORTED_MIME_TYPES.has(file.type);
|
return SUPPORTED_MIME_TYPES.has(file.type);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function unsupportedImageMessage(file: File): string {
|
export function unsupportedImageError(file: File): ToolError {
|
||||||
return `Неподдерживаемый формат файла (${file.type || 'неизвестный'}). Поддерживаются PNG, JPEG, WebP, GIF и BMP.`;
|
return new ToolError('errors.unsupportedFile', { type: file.type || 'unknown' });
|
||||||
}
|
}
|
||||||
|
|
||||||
async function decodeBitmap(bitmap: ImageBitmap): Promise<PixelImage> {
|
async function decodeBitmap(bitmap: ImageBitmap): Promise<PixelImage> {
|
||||||
@@ -22,7 +23,7 @@ async function decodeBitmap(bitmap: ImageBitmap): Promise<PixelImage> {
|
|||||||
canvas.height = bitmap.height;
|
canvas.height = bitmap.height;
|
||||||
const ctx = canvas.getContext('2d', { willReadFrequently: true });
|
const ctx = canvas.getContext('2d', { willReadFrequently: true });
|
||||||
if (!ctx) {
|
if (!ctx) {
|
||||||
throw new Error('Canvas 2D context недоступен в этом браузере');
|
throw new ToolError('errors.noCanvasCtx');
|
||||||
}
|
}
|
||||||
ctx.drawImage(bitmap, 0, 0);
|
ctx.drawImage(bitmap, 0, 0);
|
||||||
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
|
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
|
||||||
@@ -54,7 +55,7 @@ export function toDataUrl(img: PixelImage): string {
|
|||||||
canvas.height = img.height;
|
canvas.height = img.height;
|
||||||
const ctx = canvas.getContext('2d');
|
const ctx = canvas.getContext('2d');
|
||||||
if (!ctx) {
|
if (!ctx) {
|
||||||
throw new Error('Canvas 2D context недоступен в этом браузере');
|
throw new ToolError('errors.noCanvasCtx');
|
||||||
}
|
}
|
||||||
ctx.putImageData(new ImageData(img.data, img.width, img.height), 0, 0);
|
ctx.putImageData(new ImageData(img.data, img.width, img.height), 0, 0);
|
||||||
return canvas.toDataURL('image/png');
|
return canvas.toDataURL('image/png');
|
||||||
@@ -67,7 +68,7 @@ export function toBase64(img: PixelImage): string {
|
|||||||
export async function decodeTextImage(text: string): Promise<PixelImage> {
|
export async function decodeTextImage(text: string): Promise<PixelImage> {
|
||||||
const cleaned = text.trim().replace(/^data:[^,]*,/, '');
|
const cleaned = text.trim().replace(/^data:[^,]*,/, '');
|
||||||
if (cleaned.length === 0) {
|
if (cleaned.length === 0) {
|
||||||
throw new Error('Вставьте base64-строку или data-uri изображения');
|
throw new ToolError('errors.badBase64');
|
||||||
}
|
}
|
||||||
const binary = atob(cleaned);
|
const binary = atob(cleaned);
|
||||||
const bytes = Uint8Array.from(binary, (ch) => ch.charCodeAt(0));
|
const bytes = Uint8Array.from(binary, (ch) => ch.charCodeAt(0));
|
||||||
@@ -84,7 +85,7 @@ export async function encode(
|
|||||||
}
|
}
|
||||||
if (mime === 'image/jpeg' || mime === 'image/webp') {
|
if (mime === 'image/jpeg' || mime === 'image/webp') {
|
||||||
if (quality !== undefined && (quality < 0 || quality > 1)) {
|
if (quality !== undefined && (quality < 0 || quality > 1)) {
|
||||||
throw new RangeError('quality должен быть в диапазоне 0..1');
|
throw new ToolError('errors.qualityRange');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const canvas = document.createElement('canvas');
|
const canvas = document.createElement('canvas');
|
||||||
@@ -92,7 +93,7 @@ export async function encode(
|
|||||||
canvas.height = img.height;
|
canvas.height = img.height;
|
||||||
const ctx = canvas.getContext('2d');
|
const ctx = canvas.getContext('2d');
|
||||||
if (!ctx) {
|
if (!ctx) {
|
||||||
throw new Error('Canvas 2D context недоступен в этом браузере');
|
throw new ToolError('errors.noCanvasCtx');
|
||||||
}
|
}
|
||||||
ctx.putImageData(new ImageData(img.data, img.width, img.height), 0, 0);
|
ctx.putImageData(new ImageData(img.data, img.width, img.height), 0, 0);
|
||||||
return await canvasToBlob(canvas, mime, quality);
|
return await canvasToBlob(canvas, mime, quality);
|
||||||
@@ -101,7 +102,10 @@ export async function encode(
|
|||||||
function canvasToBlob(canvas: HTMLCanvasElement, mime: OutputMime, quality?: number): Promise<Blob> {
|
function canvasToBlob(canvas: HTMLCanvasElement, mime: OutputMime, quality?: number): Promise<Blob> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
canvas.toBlob(
|
canvas.toBlob(
|
||||||
(blob) => (blob ? resolve(blob) : reject(new Error(`Браузер не поддерживает кодирование в ${mime}`))),
|
(blob) =>
|
||||||
|
blob
|
||||||
|
? resolve(blob)
|
||||||
|
: reject(new ToolError('errors.encodeUnsupported', { mime })),
|
||||||
mime,
|
mime,
|
||||||
quality
|
quality
|
||||||
);
|
);
|
||||||
@@ -127,7 +131,7 @@ export function replaceExtension(filename: string, ext: string): string {
|
|||||||
export async function decodeSvgText(text: string, targetWidth?: number): Promise<PixelImage> {
|
export async function decodeSvgText(text: string, targetWidth?: number): Promise<PixelImage> {
|
||||||
const trimmed = text.trim();
|
const trimmed = text.trim();
|
||||||
if (trimmed.length === 0) {
|
if (trimmed.length === 0) {
|
||||||
throw new Error('Вставьте разметку SVG');
|
throw new ToolError('errors.svgSize');
|
||||||
}
|
}
|
||||||
const blob = new Blob([trimmed], { type: 'image/svg+xml' });
|
const blob = new Blob([trimmed], { type: 'image/svg+xml' });
|
||||||
const url = URL.createObjectURL(blob);
|
const url = URL.createObjectURL(blob);
|
||||||
@@ -135,7 +139,7 @@ export async function decodeSvgText(text: string, targetWidth?: number): Promise
|
|||||||
const img = new Image();
|
const img = new Image();
|
||||||
await new Promise<void>((resolve, reject) => {
|
await new Promise<void>((resolve, reject) => {
|
||||||
img.onload = () => resolve();
|
img.onload = () => resolve();
|
||||||
img.onerror = () => reject(new Error('Не удалось загрузить SVG — проверьте разметку'));
|
img.onerror = () => reject(new ToolError('errors.svgLoad'));
|
||||||
img.src = url;
|
img.src = url;
|
||||||
});
|
});
|
||||||
const w = targetWidth ?? img.naturalWidth ?? 300;
|
const w = targetWidth ?? img.naturalWidth ?? 300;
|
||||||
@@ -145,7 +149,7 @@ export async function decodeSvgText(text: string, targetWidth?: number): Promise
|
|||||||
canvas.width = w;
|
canvas.width = w;
|
||||||
canvas.height = h;
|
canvas.height = h;
|
||||||
const ctx = canvas.getContext('2d', { willReadFrequently: true });
|
const ctx = canvas.getContext('2d', { willReadFrequently: true });
|
||||||
if (!ctx) throw new Error('Canvas 2D context недоступен в этом браузере');
|
if (!ctx) throw new ToolError('errors.noCanvasCtx');
|
||||||
ctx.drawImage(img, 0, 0, w, h);
|
ctx.drawImage(img, 0, 0, w, h);
|
||||||
const imageData = ctx.getImageData(0, 0, w, h);
|
const imageData = ctx.getImageData(0, 0, w, h);
|
||||||
return { width: imageData.width, height: imageData.height, data: imageData.data };
|
return { width: imageData.width, height: imageData.height, data: imageData.data };
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { PixelImage } from './types';
|
import type { PixelImage } from './types';
|
||||||
|
import { ToolError } from './errors';
|
||||||
|
|
||||||
export function pixelsToHex(img: PixelImage): string {
|
export function pixelsToHex(img: PixelImage): string {
|
||||||
const rows: string[] = [];
|
const rows: string[] = [];
|
||||||
@@ -15,20 +16,21 @@ export function pixelsToHex(img: PixelImage): string {
|
|||||||
|
|
||||||
export function hexToPixels(text: string, width: number): PixelImage {
|
export function hexToPixels(text: string, width: number): PixelImage {
|
||||||
if (!Number.isInteger(width) || width < 1) {
|
if (!Number.isInteger(width) || width < 1) {
|
||||||
throw new Error('Укажите ширину изображения (целое число >= 1)');
|
throw new ToolError('errors.widthInt');
|
||||||
}
|
}
|
||||||
const tokens = text.trim().split(/\s+/).filter((t) => t.length > 0);
|
const tokens = text.trim().split(/\s+/).filter((t) => t.length > 0);
|
||||||
if (tokens.length === 0) {
|
if (tokens.length === 0) {
|
||||||
throw new Error('Вставьте hex-данные пикселей');
|
throw new ToolError('errors.noHexPixels');
|
||||||
}
|
}
|
||||||
if (tokens.some((t) => !/^[0-9a-fA-F]{8}$/.test(t))) {
|
if (tokens.some((t) => !/^[0-9a-fA-F]{8}$/.test(t))) {
|
||||||
throw new Error('Каждый пиксель должен быть 8 hex-символов RRGGBBAA, разделённых пробелами');
|
throw new ToolError('errors.badPixelToken');
|
||||||
}
|
}
|
||||||
const height = tokens.length / width;
|
const height = tokens.length / width;
|
||||||
if (!Number.isInteger(height)) {
|
if (!Number.isInteger(height)) {
|
||||||
throw new Error(
|
throw new ToolError('errors.pixelCountMismatch', {
|
||||||
`Число пикселей (${tokens.length}) не делится на ширину ${width} без остатка`
|
count: tokens.length,
|
||||||
);
|
width
|
||||||
|
});
|
||||||
}
|
}
|
||||||
const data = new Uint8ClampedArray(tokens.length * 4);
|
const data = new Uint8ClampedArray(tokens.length * 4);
|
||||||
tokens.forEach((token, index) => {
|
tokens.forEach((token, index) => {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import type { CategoryId } from '../categories';
|
|||||||
export const LOCALES = ['ru', 'en'] as const;
|
export const LOCALES = ['ru', 'en'] as const;
|
||||||
export type Locale = (typeof LOCALES)[number];
|
export type Locale = (typeof LOCALES)[number];
|
||||||
|
|
||||||
export const BASE_LOCALE: Locale = 'ru';
|
export const BASE_LOCALE: Locale = 'en';
|
||||||
|
|
||||||
export const LOCALE_TAGS: Record<Locale, string> = {
|
export const LOCALE_TAGS: Record<Locale, string> = {
|
||||||
ru: 'ru-RU',
|
ru: 'ru-RU',
|
||||||
@@ -15,12 +15,14 @@ export function isLocale(value: unknown): value is Locale {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type ToolStrings = {
|
export type ToolStrings = {
|
||||||
title: string;
|
title?: string;
|
||||||
description: string;
|
description?: string;
|
||||||
/** Подписи параметров по их id. */
|
/** Подписи параметров по их id. */
|
||||||
params?: Record<string, string>;
|
params?: Record<string, string>;
|
||||||
/** Подписи опций select: paramId -> value -> label. */
|
/** Подписи опций select: paramId -> value -> label. */
|
||||||
options?: Record<string, Record<string, string>>;
|
options?: Record<string, Record<string, string>>;
|
||||||
|
/** Тексты-результаты текстовых инструментов (analyze): ключ -> строка. */
|
||||||
|
results?: Record<string, string>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type HeaderStrings = {
|
export type HeaderStrings = {
|
||||||
|
|||||||
+52
-3
@@ -1,4 +1,4 @@
|
|||||||
import type { Dict } from './dict';
|
import type { Dict } from './dict';
|
||||||
|
|
||||||
export const en: Dict = {
|
export const en: Dict = {
|
||||||
header: {
|
header: {
|
||||||
@@ -108,7 +108,56 @@ export const en: Dict = {
|
|||||||
noImageRun: 'This tool does not process images',
|
noImageRun: 'This tool does not process images',
|
||||||
workerFailed: 'Worker execution failed',
|
workerFailed: 'Worker execution failed',
|
||||||
workerUnavailable: 'Worker is unavailable',
|
workerUnavailable: 'Worker is unavailable',
|
||||||
notFound: 'Tool not found'
|
notFound: 'Tool not found',
|
||||||
|
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',
|
||||||
|
resizeSize: 'Width and/or height must be positive',
|
||||||
|
cropSize: 'Crop width and height must be positive',
|
||||||
|
sizePositive: 'Dimensions must be positive and finite'
|
||||||
},
|
},
|
||||||
tools: {}
|
tools: {
|
||||||
|
'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.'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
import { getLocale, initLocale, setLocale } from './locale.svelte';
|
import { getLocale, initLocale, setLocale } from './locale.svelte';
|
||||||
import { interpolate, t } from './t';
|
import { interpolate, t } from './t';
|
||||||
import { ru } from './ru';
|
import { en } from './en';
|
||||||
|
|
||||||
type Store = Record<string, string>;
|
type Store = Record<string, string>;
|
||||||
|
|
||||||
@@ -19,7 +19,7 @@ function stubStorage(): { store: Store } {
|
|||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
setLocale('ru');
|
setLocale('ru');
|
||||||
delete (ru.home as Record<string, string>).onlyRuKey;
|
delete (en.home as Record<string, string>).onlyEnKey;
|
||||||
vi.unstubAllGlobals();
|
vi.unstubAllGlobals();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -30,10 +30,10 @@ describe('t', () => {
|
|||||||
expect(t('header.catalog')).toBe('Catalog');
|
expect(t('header.catalog')).toBe('Catalog');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('фолбэк на базовую локаль, если в активной нет ключа', () => {
|
it('фолбэк на английскую базу, если в активной локали нет ключа', () => {
|
||||||
(ru.home as Record<string, string>).onlyRuKey = 'Только русская строка';
|
(en.home as Record<string, string>).onlyEnKey = 'Only English string';
|
||||||
setLocale('en');
|
setLocale('ru');
|
||||||
expect(t('home.onlyRuKey')).toBe('Только русская строка');
|
expect(t('home.onlyEnKey')).toBe('Only English string');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('неизвестный путь возвращает сам путь', () => {
|
it('неизвестный путь возвращает сам путь', () => {
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { TOOLS } from '../registry';
|
||||||
|
import { normalizeForSearch, scoreDoc, type SearchDoc } from './matching';
|
||||||
|
import { setLocale } from './locale.svelte';
|
||||||
|
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');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
/**
|
||||||
|
* Нормализация строки для поиска: нижний регистр, ё→е, снятие диакритики (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;
|
||||||
|
}
|
||||||
+400
-3
@@ -1,4 +1,4 @@
|
|||||||
import type { Dict } from './dict';
|
import type { Dict } from './dict';
|
||||||
|
|
||||||
export const ru: Dict = {
|
export const ru: Dict = {
|
||||||
header: {
|
header: {
|
||||||
@@ -108,7 +108,404 @@ export const ru: Dict = {
|
|||||||
noImageRun: 'Этот инструмент не обрабатывает изображения',
|
noImageRun: 'Этот инструмент не обрабатывает изображения',
|
||||||
workerFailed: 'Ошибка исполнения в воркере',
|
workerFailed: 'Ошибка исполнения в воркере',
|
||||||
workerUnavailable: 'Воркер недоступен',
|
workerUnavailable: 'Воркер недоступен',
|
||||||
notFound: 'Инструмент не найден'
|
notFound: 'Инструмент не найден',
|
||||||
|
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}" должен быть строкой',
|
||||||
|
resizeSize: 'Ширина и/или высота должны быть положительными',
|
||||||
|
cropSize: 'Ширина и высота области обрезки должны быть положительными',
|
||||||
|
sizePositive: 'Размеры должны быть положительными и конечными'
|
||||||
},
|
},
|
||||||
tools: {}
|
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: 'По вертикали' } }
|
||||||
|
},
|
||||||
|
'png-is-grayscale': {
|
||||||
|
title: 'Проверить: PNG монохромный?',
|
||||||
|
description: 'Сообщает, состоит ли изображение только из оттенков серого.',
|
||||||
|
results: {
|
||||||
|
grayscaleYes: 'Да — все пиксели являются оттенками серого.',
|
||||||
|
grayscaleNo: 'Нет — найдены цветные пиксели.'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
'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: 'Квадрат — стороны равны.'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { setLocale } from './locale.svelte';
|
||||||
|
import { t } from './t';
|
||||||
|
import { LOCALE_TAGS } from './dict';
|
||||||
|
import { normalizeForSearch, scoreDoc } from './matching';
|
||||||
|
import { toolSearchDoc } from './tool-strings';
|
||||||
|
import { TOOLS } from '../registry';
|
||||||
|
|
||||||
|
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import type { ParamDef, ToolEntry } from '$lib/registry';
|
||||||
|
import type { SearchDoc } from './matching';
|
||||||
|
import { getMergedDict } from './locale.svelte';
|
||||||
|
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
|
||||||
|
])
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -170,7 +170,7 @@ describe('run инструмента resize-png', () => {
|
|||||||
|
|
||||||
it('обе стороны 0 — человекочитаемая ошибка', async () => {
|
it('обе стороны 0 — человекочитаемая ошибка', async () => {
|
||||||
await expect(runResize({ width: 0, height: 0, keepAspect: true })).rejects.toThrow(
|
await expect(runResize({ width: 0, height: 0, keepAspect: true })).rejects.toThrow(
|
||||||
'Укажите ширину'
|
'errors.resizeSize'
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+245
-248
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,5 @@
|
|||||||
import type { PixelImage } from '../core/types';
|
import type { PixelImage } from '../core/types';
|
||||||
|
import { ToolError } from '../core/errors';
|
||||||
|
|
||||||
type MaybeRunnable = {
|
type MaybeRunnable = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -63,6 +64,8 @@ function ensureWorker(): Worker | null {
|
|||||||
height?: number;
|
height?: number;
|
||||||
data?: Uint8ClampedArray;
|
data?: Uint8ClampedArray;
|
||||||
error?: string;
|
error?: string;
|
||||||
|
errorKey?: string;
|
||||||
|
errorVars?: Record<string, string | number>;
|
||||||
};
|
};
|
||||||
const entry = pending.get(payload.id);
|
const entry = pending.get(payload.id);
|
||||||
if (!entry) return;
|
if (!entry) return;
|
||||||
@@ -73,6 +76,8 @@ function ensureWorker(): Worker | null {
|
|||||||
height: payload.height,
|
height: payload.height,
|
||||||
data: new Uint8ClampedArray(payload.data)
|
data: new Uint8ClampedArray(payload.data)
|
||||||
});
|
});
|
||||||
|
} else if (payload.errorKey) {
|
||||||
|
entry.reject(new ToolError(payload.errorKey, payload.errorVars));
|
||||||
} else {
|
} else {
|
||||||
entry.reject(new Error(payload.error ?? 'errors.workerFailed'));
|
entry.reject(new Error(payload.error ?? 'errors.workerFailed'));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
/// <reference lib="webworker" />
|
/// <reference lib="webworker" />
|
||||||
import type { PixelImage } from '../core/types';
|
import type { PixelImage } from '../core/types';
|
||||||
|
import { ToolError } from '../core/errors';
|
||||||
import { getTool, sanitizeParams } from '../registry';
|
import { getTool, sanitizeParams } from '../registry';
|
||||||
|
|
||||||
type WorkerRequest = {
|
type WorkerRequest = {
|
||||||
@@ -34,10 +35,13 @@ async function handle(request: WorkerRequest): Promise<void> {
|
|||||||
};
|
};
|
||||||
(self as unknown as Worker).postMessage(payload, [output.data.buffer]);
|
(self as unknown as Worker).postMessage(payload, [output.data.buffer]);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
const toolError = e instanceof ToolError ? e : undefined;
|
||||||
(self as unknown as Worker).postMessage({
|
(self as unknown as Worker).postMessage({
|
||||||
id: request.id,
|
id: request.id,
|
||||||
ok: false,
|
ok: false,
|
||||||
error: e instanceof Error ? e.message : String(e)
|
error: e instanceof Error ? e.message : String(e),
|
||||||
|
errorKey: toolError?.key,
|
||||||
|
errorVars: toolError?.vars
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ describe('createStep', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('бросает ошибку для неизвестного инструмента', () => {
|
it('бросает ошибку для неизвестного инструмента', () => {
|
||||||
expect(() => createStep('no-such-tool')).toThrow(/не найден/);
|
expect(() => createStep('no-such-tool')).toThrow(/errors\.toolNotFound/);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { getTool, sanitizeParams } from '../registry';
|
import { getTool, sanitizeParams } from '../registry';
|
||||||
|
import { ToolError } from '../core/errors';
|
||||||
|
|
||||||
export type PipelineStep = {
|
export type PipelineStep = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -14,7 +15,7 @@ const STORAGE_KEY = 'workspace-pipeline';
|
|||||||
export function createStep(toolId: string): PipelineStep {
|
export function createStep(toolId: string): PipelineStep {
|
||||||
const tool = getTool(toolId);
|
const tool = getTool(toolId);
|
||||||
if (!tool) {
|
if (!tool) {
|
||||||
throw new Error(`Инструмент "${toolId}" не найден`);
|
throw new ToolError('errors.toolNotFound', { id: toolId });
|
||||||
}
|
}
|
||||||
return { id: newStepId(), toolId, values: {} };
|
return { id: newStepId(), toolId, values: {} };
|
||||||
}
|
}
|
||||||
@@ -31,17 +32,17 @@ export function parseDocument(raw: string): PipelineStep[] {
|
|||||||
try {
|
try {
|
||||||
parsed = JSON.parse(raw);
|
parsed = JSON.parse(raw);
|
||||||
} catch {
|
} catch {
|
||||||
throw new Error('Файл не является корректным JSON');
|
throw new ToolError('errors.badJson');
|
||||||
}
|
}
|
||||||
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
|
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
|
||||||
throw new Error('Структура файла не похожа на цепочку шагов');
|
throw new ToolError('errors.badPipelineShape');
|
||||||
}
|
}
|
||||||
const doc = parsed as { version?: unknown; steps?: unknown };
|
const doc = parsed as { version?: unknown; steps?: unknown };
|
||||||
if (doc.version !== PIPELINE_VERSION) {
|
if (doc.version !== PIPELINE_VERSION) {
|
||||||
throw new Error(`Неподдерживаемая версия цепочки: ${String(doc.version)}`);
|
throw new ToolError('errors.pipelineVersion', { version: String(doc.version) });
|
||||||
}
|
}
|
||||||
if (!Array.isArray(doc.steps)) {
|
if (!Array.isArray(doc.steps)) {
|
||||||
throw new Error('В файле нет списка шагов');
|
throw new ToolError('errors.noSteps');
|
||||||
}
|
}
|
||||||
const steps: PipelineStep[] = [];
|
const steps: PipelineStep[] = [];
|
||||||
for (const rawStep of doc.steps) {
|
for (const rawStep of doc.steps) {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { getTool } from '$lib/registry';
|
import { getTool } from '$lib/registry';
|
||||||
import { t } from '$lib/i18n/t';
|
import { t } from '$lib/i18n/t';
|
||||||
|
import { toolTitle } from '$lib/i18n/tool-strings';
|
||||||
import ToolPage from '$lib/components/ToolPage.svelte';
|
import ToolPage from '$lib/components/ToolPage.svelte';
|
||||||
import ToolSearch from '$lib/components/search/ToolSearch.svelte';
|
import ToolSearch from '$lib/components/search/ToolSearch.svelte';
|
||||||
import Button from '$lib/components/ui/Button.svelte';
|
import Button from '$lib/components/ui/Button.svelte';
|
||||||
@@ -56,7 +57,7 @@
|
|||||||
{@const restoreId = lastToolId}
|
{@const restoreId = lastToolId}
|
||||||
<div class="restore-row">
|
<div class="restore-row">
|
||||||
<Button variant="secondary" fullWidth onclick={() => openTool(restoreId, true)}>
|
<Button variant="secondary" fullWidth onclick={() => openTool(restoreId, true)}>
|
||||||
{t('home.restoreLast', { title: getTool(restoreId)?.title ?? '' })}
|
{t('home.restoreLast', { title: getTool(restoreId) ? toolTitle(getTool(restoreId)!) : '' })}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { CATEGORIES } from '$lib/categories';
|
import { CATEGORIES } from '$lib/categories';
|
||||||
import { t } from '$lib/i18n/t';
|
import { t } from '$lib/i18n/t';
|
||||||
|
import { toolDescription, toolTitle } from '$lib/i18n/tool-strings';
|
||||||
import ToolCard from '$lib/components/search/ToolCard.svelte';
|
import ToolCard from '$lib/components/search/ToolCard.svelte';
|
||||||
import { TOOLS } from '$lib/registry';
|
import { TOOLS } from '$lib/registry';
|
||||||
</script>
|
</script>
|
||||||
@@ -23,8 +24,8 @@
|
|||||||
<div class="panel">
|
<div class="panel">
|
||||||
<ToolCard
|
<ToolCard
|
||||||
toolId={tool.id}
|
toolId={tool.id}
|
||||||
title={tool.title}
|
title={toolTitle(tool)}
|
||||||
description={tool.description}
|
description={toolDescription(tool)}
|
||||||
href="/tools/{tool.id}"
|
href="/tools/{tool.id}"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import ToolPage from '$lib/components/ToolPage.svelte';
|
import ToolPage from '$lib/components/ToolPage.svelte';
|
||||||
import { getTool } from '$lib/registry';
|
import { getTool } from '$lib/registry';
|
||||||
import { t } from '$lib/i18n/t';
|
import { t } from '$lib/i18n/t';
|
||||||
|
import { toolDescription, toolTitle } from '$lib/i18n/tool-strings';
|
||||||
|
|
||||||
let { data } = $props();
|
let { data } = $props();
|
||||||
|
|
||||||
@@ -9,9 +10,9 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
<title>{tool?.title ?? t('toolPage.fallbackTitle')} — easy-png-tools</title>
|
<title>{tool ? toolTitle(tool) : t('toolPage.fallbackTitle')} — easy-png-tools</title>
|
||||||
{#if tool}
|
{#if tool}
|
||||||
<meta name="description" content={tool.description} />
|
<meta name="description" content={toolDescription(tool)} />
|
||||||
{/if}
|
{/if}
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user