Compare commits

..
6 Commits
44 changed files with 1274 additions and 405 deletions
@@ -1,6 +1,6 @@
# План: i18n — русский и английский
> Статус: план к выполнению.
> **СТАТУС: ВЫПОЛНЕН 25.08.2026.** Базовая локаль — английский (реестр), русский — перевод в словаре; строки инструментов в реестре переведены на английский в рамках этапа C.
## 1. Что получается
@@ -12,7 +12,7 @@
- Без сегментов языка в URL. Статика пререндерится на русском; переключение на клиенте, выбор живёт в localStorage. hreflang/canonical и вторая копия страниц для поисковиков — не в этой волне.
- Ядро не знает о локалях. Ошибки в 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-модуля
@@ -27,7 +27,7 @@
- A. Инфраструктура — модуль состояния, функция перевода с фолбэком и интерполяцией, словари-скелеты со всеми секциями (заполнены header и categories как пилот), переключатель RU/EN в шапке, реактивный lang. Тесты: фолбэк на ru при отсутствии ключа в en, интерполяция, персист локали.
- 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 активной локали. Тесты нормализации: ё/е, диакритика, английский запрос при русской локали и наоборот.
- E. Ошибки на кодах — около 40 мест throw в core, registry, pipeline заменяются на коды; исполнитель переводит код в текст активной локали, неизвестные сообщения показываются как есть. Пять тестов, матчащих русские подстроки ошибок (executor, pipeline, registry, io, alpha), переходят на коды — это упрощает ассерты. Русские describe/it в тестах не трогаем.
- F. Итог — полный прогон, смоук чеклист §6, архивация плана.
+1 -19
View File
@@ -1,21 +1,3 @@
# Backlog
## 1. Несколько языков — оценка: L, разбита на подзадачи
### 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 наизусть — не входит, отдельно при необходимости.
(пусто — выполненные волны лежат в docs/archive/; кандидат на следующую волну: текст и водяные знаки — add-text, watermark, штампы, см. roadmap фаза 1.5 и план wave3 §7.)
+1 -1
View File
@@ -1,5 +1,5 @@
<!doctype html>
<html lang="ru">
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
+23 -1
View File
@@ -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

+3 -3
View File
@@ -1,11 +1,11 @@
<script lang="ts">
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';
interface Props {
onFile: (file: File) => void;
onError?: (message: string) => void;
onError?: (e: unknown) => void;
label?: string;
children: Snippet;
}
@@ -42,7 +42,7 @@
const file = event.dataTransfer?.files[0];
if (!file) return;
if (!isSupportedImage(file)) {
onError?.(unsupportedImageMessage(file));
onError?.(unsupportedImageError(file));
return;
}
onFile(file);
+3 -3
View File
@@ -1,10 +1,10 @@
<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';
interface Props {
onFile: (file: File) => void;
onError?: (message: string) => void;
onError?: (e: unknown) => void;
label?: string;
}
@@ -33,7 +33,7 @@
function accept(file: File | undefined | null) {
if (!file) return;
if (!isSupportedImage(file)) {
onError?.(unsupportedImageMessage(file));
onError?.(unsupportedImageError(file));
return;
}
onFile(file);
+13 -7
View File
@@ -4,10 +4,12 @@
import SelectField from './ui/SelectField.svelte';
import SliderField from './ui/SliderField.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';
interface Props {
tool: ToolEntry;
params: ParamDef[];
values: Record<string, any>;
pipetteTargetId?: string | null;
@@ -17,6 +19,7 @@
}
let {
tool,
params,
values = $bindable(),
pipetteTargetId = null,
@@ -33,11 +36,11 @@
{#each params as param (param.id)}
<div class="field">
{#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'}
<TextField
id={param.id}
label={param.label}
label={paramLabel(tool, param)}
type="number"
min={param.min}
max={param.max}
@@ -47,7 +50,7 @@
{:else if param.type === 'slider'}
<SliderField
id={param.id}
label={param.label}
label={paramLabel(tool, param)}
min={param.min}
max={param.max}
step={param.step}
@@ -57,14 +60,17 @@
{:else if param.type === 'select'}
<SelectField
id={param.id}
label={param.label}
options={param.options}
label={paramLabel(tool, param)}
options={param.options.map((o) => ({
value: o.value,
label: optionLabel(tool, param, o.value)
}))}
bind:value={values[param.id]}
/>
{:else if param.type === 'color'}
<ColorField
id={param.id}
label={param.label}
label={paramLabel(tool, param)}
bind:value={values[param.id]}
pipetteActive={pipetteTargetId === param.id}
onPipetteToggle={() => onPipetteToggle?.(param.id)}
+15 -9
View File
@@ -1,6 +1,7 @@
<script lang="ts">
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 { defaultParams, getTool, outputOf, sanitizeParams, type ToolEntry } from '$lib/registry';
import { loadStoredSteps, newStepId, saveSteps, type PipelineStep } from '$lib/tools/pipeline';
@@ -12,6 +13,7 @@
import { createAutoRunner } from '$lib/tools/auto-run';
import { executeStep } from '$lib/tools/executor';
import { t } from '$lib/i18n/t';
import { toolDescription, toolTitle } from '$lib/i18n/tool-strings';
import ParamsCard from './tool/ParamsCard.svelte';
import ResultCard from './tool/ResultCard.svelte';
import SourceCard from './tool/SourceCard.svelte';
@@ -176,9 +178,8 @@
try {
current = await executeStep(stepTool, current, sanitizeParams(stepTool, step.values));
} catch (e) {
const rawMsg = e instanceof Error ? e.message : String(e);
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;
@@ -214,10 +215,14 @@
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) {
status = source ? 'loaded' : 'idle';
const raw = e instanceof Error ? e.message : String(e);
errorText = t(raw);
errorText = errorMessage(e);
}
function reset() {
@@ -242,7 +247,7 @@
if (file) {
event.preventDefault();
if (!isSupportedImage(file)) {
errorText = unsupportedImageMessage(file);
errorText = errorMessage(unsupportedImageError(file));
return;
}
handleFile(file);
@@ -255,8 +260,8 @@
<svelte:window onpaste={handlePaste} />
<section>
<h1>{tool.title}</h1>
<p class="description text-muted">{tool.description}</p>
<h1>{toolTitle(tool)}</h1>
<p class="description text-muted">{toolDescription(tool)}</p>
{#if errorText}
<div class="error-banner" role="alert">{errorText}</div>
@@ -273,7 +278,7 @@
<SourceCard
{source}
onFile={handleFile}
onError={(message) => (errorText = message)}
onError={(e) => (errorText = errorMessage(e))}
onReset={reset}
pipetteActive={!!pipetteTargetId}
onPickColor={handlePickColor}
@@ -307,6 +312,7 @@
<span class="edge-legend" aria-hidden="true">{t('toolPage.legendParams')}</span>
</div>
<ParamsCard
tool={tool}
params={tool.params}
bind:values
{pipetteTargetId}
@@ -2,6 +2,7 @@
import { outputOf, sanitizeParams, type ToolEntry } from '$lib/registry';
import type { PixelImage } from '$lib/core/types';
import { t } from '$lib/i18n/t';
import { toolTitle } from '$lib/i18n/tool-strings';
import DownloadButton from '../DownloadButton.svelte';
import Button from '../ui/Button.svelte';
import EmptyState from '../ui/EmptyState.svelte';
@@ -48,7 +49,7 @@
{#if StepIcon}
<span class="step-icon" aria-hidden="true"><StepIcon size={14} strokeWidth={2} /></span>
{/if}
{t('chain.stepLabel', { n: index + 1, title: tool.title })}
{t('chain.stepLabel', { n: index + 1, title: toolTitle(tool) })}
<button
type="button"
class="remove"
@@ -99,7 +100,7 @@
<div class="params-sep">
<span class="edge-legend" aria-hidden="true">{t('chain.paramsLegend')}</span>
</div>
<ParamsCard params={tool.params} bind:values />
<ParamsCard tool={tool} params={tool.params} bind:values />
{/if}
</div>
+10 -30
View File
@@ -1,6 +1,10 @@
<script lang="ts">
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 { toolDescription, toolSearchDoc, toolTitle } from '$lib/i18n/tool-strings';
import ToolCard from './ToolCard.svelte';
interface Props {
@@ -23,51 +27,27 @@
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 q = query.trim().toLowerCase();
const q = normalizeForSearch(query.trim());
const found: Match[] = [];
for (const tool of candidates) {
const s = score(tool, q);
const s = scoreDoc(toolSearchDoc(tool), q);
if (s !== null && s > 0) {
found.push({
id: tool.id,
title: tool.title,
description: tool.description,
title: toolTitle(tool),
description: toolDescription(tool),
popularity: tool.popularity ?? 50,
score: s
});
}
}
const collator = new Intl.Collator(LOCALE_TAGS[getLocale()]);
found.sort(
(a, b) =>
b.score - a.score ||
b.popularity - a.popularity ||
a.title.localeCompare(b.title)
collator.compare(a.title, b.title)
);
return found.slice(0, 12);
});
@@ -1,9 +1,10 @@
<script lang="ts">
import ParamForm from '../ParamForm.svelte';
import type { ParamDef } from '$lib/registry';
import type { ParamDef, ToolEntry } from '$lib/registry';
import { t } from '$lib/i18n/t';
interface Props {
tool: ToolEntry;
params: ParamDef[];
values: Record<string, any>;
pipetteTargetId?: string | null;
@@ -13,6 +14,7 @@
}
let {
tool,
params,
values = $bindable(),
pipetteTargetId = null,
@@ -25,6 +27,7 @@
<div class="root">
{#if params.length > 0 || hasMask}
<ParamForm
{tool}
{params}
bind:values
{pipetteTargetId}
@@ -64,7 +64,7 @@
{:else if tool.resultType === 'text'}
<div class="media">
{#if textResult !== null}
<TextResult text={textResult} filename={tool.id} />
<TextResult text={textResult} filename={tool.id} toolId={tool.id} />
{/if}
</div>
{:else}
@@ -9,7 +9,7 @@
interface Props {
source: PixelImage | null;
onFile: (file: File) => void;
onError: (message: string) => void;
onError: (e: unknown) => void;
onReset: () => void;
pipetteActive?: boolean;
onPickColor?: (hex: string) => void;
+10 -4
View File
@@ -1,29 +1,35 @@
<script lang="ts">
import { downloadBlob } from '$lib/core/io';
import { t } from '$lib/i18n/t';
import { getMergedDict } from '$lib/i18n/locale.svelte';
interface Props {
text: 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);
async function copy() {
await navigator.clipboard.writeText(text);
await navigator.clipboard.writeText(shown);
copied = true;
setTimeout(() => (copied = false), 1500);
}
function download() {
downloadBlob(new Blob([text], { type: 'text/plain' }), `${filename}.txt`);
downloadBlob(new Blob([shown], { type: 'text/plain' }), `${filename}.txt`);
}
</script>
<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">
<button type="button" class="secondary" onclick={copy}>
{copied ? t('textResult.copied') : t('textResult.copy')}
+3 -2
View File
@@ -1,4 +1,5 @@
import { parseHex } from './alpha';
import { ToolError } from './errors';
import { clonePixelImage, createPixelImage, type PixelImage } from './types';
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 {
const det = a * d - b * c;
if (Math.abs(det) < 1e-12) {
throw new Error('Вырожденная матрица трансформации');
throw new ToolError('errors.badTransform');
}
const ia = d / 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 ky = Math.tan((degY * Math.PI) / 180);
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]);
}
+1 -1
View File
@@ -181,6 +181,6 @@ describe('parseHex', () => {
});
it.each(['zzz', '12345', '##ff', ''])('бросает ошибку на "%s"', (bad) => {
expect(() => parseHex(bad)).toThrow(/Некорректный HEX/);
expect(() => parseHex(bad)).toThrow(/errors\.badHex/);
});
});
+2 -1
View File
@@ -1,4 +1,5 @@
import { createPixelImage, type PixelImage } from './types';
import { ToolError } from './errors';
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] {
const match = /^#?([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(hex.trim());
if (!match) {
throw new Error(`Некорректный HEX-цвет: "${hex}"`);
throw new ToolError('errors.badHex', { value: hex });
}
const digits = match[1];
if (digits.length === 3) {
+3 -2
View File
@@ -1,4 +1,5 @@
import { clonePixelImage, createPixelImage, type PixelImage } from './types';
import { ToolError } from './errors';
export type RgbChannel = 'red' | 'green' | 'blue';
@@ -163,7 +164,7 @@ export function twoColors(
function parseColor(hex: string): [number, number, number] {
const match = /^#([0-9a-f]{6})$/i.exec(hex.trim());
if (!match) {
throw new Error(`Некорректный HEX-цвет: "${hex}"`);
throw new ToolError('errors.badHex', { value: hex });
}
const digits = match[1];
return [
@@ -289,7 +290,7 @@ export function tint(
): PixelImage {
const s = clamp(strengthPercent, 0, 100) / 100;
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 tr = parseInt(d.slice(0, 2), 16) / 255;
const tg = parseInt(d.slice(2, 4), 16) / 255;
+3 -2
View File
@@ -1,4 +1,5 @@
import { clonePixelImage, createPixelImage, type PixelImage } from './types';
import { ToolError } from './errors';
type Plane = Float64Array;
@@ -8,10 +9,10 @@ export function convolve(
size: number
): PixelImage {
if (!Number.isInteger(size) || size < 1 || size % 2 === 0) {
throw new Error('Размер ядра должен быть нечётным положительным числом');
throw new ToolError('errors.radiusInt');
}
if (kernel.length !== size * size) {
throw new Error('Длина ядра не совпадает с его размером');
throw new ToolError('errors.kernelSize');
}
const half = Math.floor(size / 2);
const out = createPixelImage(img.width, img.height);
+18
View File
@@ -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;
}
}
+4 -3
View File
@@ -1,4 +1,5 @@
import type { PixelImage } from './types';
import { ToolError } from './errors';
export function solidImage(
width: number,
@@ -6,7 +7,7 @@ export function solidImage(
rgba: [number, number, number, number]
): PixelImage {
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);
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 {
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 data = new Uint8ClampedArray(width * height * 4);
@@ -41,7 +42,7 @@ export function gradientImage(
direction: 'horizontal' | 'vertical'
): PixelImage {
if (!Number.isInteger(width) || !Number.isInteger(height) || width < 1 || height < 1) {
throw new Error('Размеры должны быть целыми числами >= 1');
throw new ToolError('errors.sizeInt');
}
const out: PixelImage = {
width,
+5 -5
View File
@@ -121,8 +121,8 @@ describe('crop', () => {
expect([...out.data]).toEqual([1, 1, 1, 1]);
});
it('бросает RangeError для области вне изображения', () => {
expect(() => crop(grid(), 5, 5, 2, 2)).toThrow(RangeError);
it('бросает ToolError для области вне изображения', () => {
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]);
});
it('бросает RangeError на некорректные размеры', () => {
it('бросает ToolError на некорректные размеры', () => {
const img = twoByTwo();
expect(() => resize(img, 0, 10)).toThrow(RangeError);
expect(() => resize(img, 10.5, 10)).toThrow(RangeError);
expect(() => resize(img, 0, 10)).toThrow(/errors\.sizeInt/);
expect(() => resize(img, 10.5, 10)).toThrow(/errors\.sizeInt/);
});
});
+3 -2
View File
@@ -1,4 +1,5 @@
import { parseHex } from './alpha';
import { ToolError } from './errors';
import { clonePixelImage, createPixelImage, type PixelImage } from './types';
export function expandCanvas(
@@ -128,7 +129,7 @@ export function crop(
const w = ex - sx;
const h = ey - sy;
if (w <= 0 || h <= 0) {
throw new RangeError('Область обрезки пуста: она целиком вне изображения');
throw new ToolError('errors.cropBounds');
}
const out = createPixelImage(w, h);
for (let row = 0; row < h; row++) {
@@ -140,7 +141,7 @@ export function crop(
export function resize(img: PixelImage, width: number, height: number): PixelImage {
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 xr = img.width / width;
+9 -8
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest';
import { isSupportedImage, unsupportedImageMessage } from './io';
import { isSupportedImage, unsupportedImageError } from './io';
describe('isSupportedImage', () => {
it.each(['image/png', 'image/jpeg', 'image/webp', 'image/gif', 'image/bmp', 'image/x-icon'])(
@@ -18,14 +18,15 @@ describe('isSupportedImage', () => {
});
});
describe('unsupportedImageMessage', () => {
it('упоминает тип файла и поддерживаемые форматы', () => {
const message = unsupportedImageMessage(new File([], 'a.txt', { type: 'text/plain' }));
expect(message).toContain('text/plain');
expect(message).toContain('PNG');
describe('unsupportedImageError', () => {
it('ключ ошибки и тип файла в vars', () => {
const err = unsupportedImageError(new File([], 'a.txt', { type: 'text/plain' }));
expect(err.key).toBe('errors.unsupportedFile');
expect(err.vars?.type).toBe('text/plain');
});
it('сообщает про неизвестный тип, когда он пуст', () => {
expect(unsupportedImageMessage(new File([], 'x'))).toContain('неизвестный');
it('пустой тип передаётся как unknown', () => {
const err = unsupportedImageError(new File([], 'x'));
expect(err.vars?.type).toBe('unknown');
});
});
+16 -12
View File
@@ -1,4 +1,5 @@
import { encodeBmpBytes } from './bmp';
import { ToolError } from './errors';
import { type PixelImage } from './types';
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);
}
export function unsupportedImageMessage(file: File): string {
return `Неподдерживаемый формат файла (${file.type || 'неизвестный'}). Поддерживаются PNG, JPEG, WebP, GIF и BMP.`;
export function unsupportedImageError(file: File): ToolError {
return new ToolError('errors.unsupportedFile', { type: file.type || 'unknown' });
}
async function decodeBitmap(bitmap: ImageBitmap): Promise<PixelImage> {
@@ -22,7 +23,7 @@ async function decodeBitmap(bitmap: ImageBitmap): Promise<PixelImage> {
canvas.height = bitmap.height;
const ctx = canvas.getContext('2d', { willReadFrequently: true });
if (!ctx) {
throw new Error('Canvas 2D context недоступен в этом браузере');
throw new ToolError('errors.noCanvasCtx');
}
ctx.drawImage(bitmap, 0, 0);
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
@@ -54,7 +55,7 @@ export function toDataUrl(img: PixelImage): string {
canvas.height = img.height;
const ctx = canvas.getContext('2d');
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);
return canvas.toDataURL('image/png');
@@ -67,7 +68,7 @@ export function toBase64(img: PixelImage): string {
export async function decodeTextImage(text: string): Promise<PixelImage> {
const cleaned = text.trim().replace(/^data:[^,]*,/, '');
if (cleaned.length === 0) {
throw new Error('Вставьте base64-строку или data-uri изображения');
throw new ToolError('errors.badBase64');
}
const binary = atob(cleaned);
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 (quality !== undefined && (quality < 0 || quality > 1)) {
throw new RangeError('quality должен быть в диапазоне 0..1');
throw new ToolError('errors.qualityRange');
}
}
const canvas = document.createElement('canvas');
@@ -92,7 +93,7 @@ export async function encode(
canvas.height = img.height;
const ctx = canvas.getContext('2d');
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);
return await canvasToBlob(canvas, mime, quality);
@@ -101,7 +102,10 @@ export async function encode(
function canvasToBlob(canvas: HTMLCanvasElement, mime: OutputMime, quality?: number): Promise<Blob> {
return new Promise((resolve, reject) => {
canvas.toBlob(
(blob) => (blob ? resolve(blob) : reject(new Error(`Браузер не поддерживает кодирование в ${mime}`))),
(blob) =>
blob
? resolve(blob)
: reject(new ToolError('errors.encodeUnsupported', { mime })),
mime,
quality
);
@@ -127,7 +131,7 @@ export function replaceExtension(filename: string, ext: string): string {
export async function decodeSvgText(text: string, targetWidth?: number): Promise<PixelImage> {
const trimmed = text.trim();
if (trimmed.length === 0) {
throw new Error('Вставьте разметку SVG');
throw new ToolError('errors.svgSize');
}
const blob = new Blob([trimmed], { type: 'image/svg+xml' });
const url = URL.createObjectURL(blob);
@@ -135,7 +139,7 @@ export async function decodeSvgText(text: string, targetWidth?: number): Promise
const img = new Image();
await new Promise<void>((resolve, reject) => {
img.onload = () => resolve();
img.onerror = () => reject(new Error('Не удалось загрузить SVG — проверьте разметку'));
img.onerror = () => reject(new ToolError('errors.svgLoad'));
img.src = url;
});
const w = targetWidth ?? img.naturalWidth ?? 300;
@@ -144,8 +148,8 @@ export async function decodeSvgText(text: string, targetWidth?: number): Promise
const canvas = document.createElement('canvas');
canvas.width = w;
canvas.height = h;
const ctx = canvas.getContext('2d', { willReadFrequently: true });
if (!ctx) throw new Error('Canvas 2D context недоступен в этом браузере');
const ctx = canvas.getContext('2d', { willReadFrequently: true });
if (!ctx) throw new ToolError('errors.noCanvasCtx');
ctx.drawImage(img, 0, 0, w, h);
const imageData = ctx.getImageData(0, 0, w, h);
return { width: imageData.width, height: imageData.height, data: imageData.data };
+8 -6
View File
@@ -1,4 +1,5 @@
import type { PixelImage } from './types';
import { ToolError } from './errors';
export function pixelsToHex(img: PixelImage): string {
const rows: string[] = [];
@@ -15,20 +16,21 @@ export function pixelsToHex(img: PixelImage): string {
export function hexToPixels(text: string, width: number): PixelImage {
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);
if (tokens.length === 0) {
throw new Error('Вставьте hex-данные пикселей');
throw new ToolError('errors.noHexPixels');
}
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;
if (!Number.isInteger(height)) {
throw new Error(
`Число пикселей (${tokens.length}) не делится на ширину ${width} без остатка`
);
throw new ToolError('errors.pixelCountMismatch', {
count: tokens.length,
width
});
}
const data = new Uint8ClampedArray(tokens.length * 4);
tokens.forEach((token, index) => {
+5 -3
View File
@@ -3,7 +3,7 @@ import type { CategoryId } from '../categories';
export const LOCALES = ['ru', 'en'] as const;
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> = {
ru: 'ru-RU',
@@ -15,12 +15,14 @@ export function isLocale(value: unknown): value is Locale {
}
export type ToolStrings = {
title: string;
description: string;
title?: string;
description?: string;
/** Подписи параметров по их id. */
params?: Record<string, string>;
/** Подписи опций select: paramId -> value -> label. */
options?: Record<string, Record<string, string>>;
/** Тексты-результаты текстовых инструментов (analyze): ключ -> строка. */
results?: Record<string, string>;
};
export type HeaderStrings = {
+52 -3
View File
@@ -1,4 +1,4 @@
import type { Dict } from './dict';
import type { Dict } from './dict';
export const en: Dict = {
header: {
@@ -108,7 +108,56 @@ export const en: Dict = {
noImageRun: 'This tool does not process images',
workerFailed: 'Worker execution failed',
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.'
}
}
}
};
+6 -6
View File
@@ -1,7 +1,7 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { getLocale, initLocale, setLocale } from './locale.svelte';
import { interpolate, t } from './t';
import { ru } from './ru';
import { en } from './en';
type Store = Record<string, string>;
@@ -19,7 +19,7 @@ function stubStorage(): { store: Store } {
beforeEach(() => {
setLocale('ru');
delete (ru.home as Record<string, string>).onlyRuKey;
delete (en.home as Record<string, string>).onlyEnKey;
vi.unstubAllGlobals();
});
@@ -30,10 +30,10 @@ describe('t', () => {
expect(t('header.catalog')).toBe('Catalog');
});
it('фолбэк на базовую локаль, если в активной нет ключа', () => {
(ru.home as Record<string, string>).onlyRuKey = 'Только русская строка';
setLocale('en');
expect(t('home.onlyRuKey')).toBe('Только русская строка');
it('фолбэк на английскую базу, если в активной локали нет ключа', () => {
(en.home as Record<string, string>).onlyEnKey = 'Only English string';
setLocale('ru');
expect(t('home.onlyEnKey')).toBe('Only English string');
});
it('неизвестный путь возвращает сам путь', () => {
+94
View File
@@ -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');
}
});
});
+66
View File
@@ -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
View File
@@ -1,4 +1,4 @@
import type { Dict } from './dict';
import type { Dict } from './dict';
export const ru: Dict = {
header: {
@@ -108,7 +108,404 @@ export const ru: Dict = {
noImageRun: 'Этот инструмент не обрабатывает изображения',
workerFailed: 'Ошибка исполнения в воркере',
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: 'Квадрат — стороны равны.'
}
}
}
};
+82
View File
@@ -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');
});
});
+55
View File
@@ -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
])
};
}
+77
View File
@@ -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();
}
}
});
});
+1 -1
View File
@@ -170,7 +170,7 @@ describe('run инструмента resize-png', () => {
it('обе стороны 0 — человекочитаемая ошибка', async () => {
await expect(runResize({ width: 0, height: 0, keepAspect: true })).rejects.toThrow(
'Укажите ширину'
'errors.resizeSize'
);
});
});
+245 -248
View File
File diff suppressed because it is too large Load Diff
+5
View File
@@ -1,4 +1,5 @@
import type { PixelImage } from '../core/types';
import { ToolError } from '../core/errors';
type MaybeRunnable = {
id: string;
@@ -63,6 +64,8 @@ function ensureWorker(): Worker | null {
height?: number;
data?: Uint8ClampedArray;
error?: string;
errorKey?: string;
errorVars?: Record<string, string | number>;
};
const entry = pending.get(payload.id);
if (!entry) return;
@@ -73,6 +76,8 @@ function ensureWorker(): Worker | null {
height: payload.height,
data: new Uint8ClampedArray(payload.data)
});
} else if (payload.errorKey) {
entry.reject(new ToolError(payload.errorKey, payload.errorVars));
} else {
entry.reject(new Error(payload.error ?? 'errors.workerFailed'));
}
+5 -1
View File
@@ -1,5 +1,6 @@
/// <reference lib="webworker" />
import type { PixelImage } from '../core/types';
import { ToolError } from '../core/errors';
import { getTool, sanitizeParams } from '../registry';
type WorkerRequest = {
@@ -34,10 +35,13 @@ async function handle(request: WorkerRequest): Promise<void> {
};
(self as unknown as Worker).postMessage(payload, [output.data.buffer]);
} catch (e) {
const toolError = e instanceof ToolError ? e : undefined;
(self as unknown as Worker).postMessage({
id: request.id,
ok: false,
error: e instanceof Error ? e.message : String(e)
error: e instanceof Error ? e.message : String(e),
errorKey: toolError?.key,
errorVars: toolError?.vars
});
}
}
+1 -1
View File
@@ -9,7 +9,7 @@ describe('createStep', () => {
});
it('бросает ошибку для неизвестного инструмента', () => {
expect(() => createStep('no-such-tool')).toThrow(/не найден/);
expect(() => createStep('no-such-tool')).toThrow(/errors\.toolNotFound/);
});
});
+6 -5
View File
@@ -1,4 +1,5 @@
import { getTool, sanitizeParams } from '../registry';
import { ToolError } from '../core/errors';
export type PipelineStep = {
id: string;
@@ -14,7 +15,7 @@ const STORAGE_KEY = 'workspace-pipeline';
export function createStep(toolId: string): PipelineStep {
const tool = getTool(toolId);
if (!tool) {
throw new Error(`Инструмент "${toolId}" не найден`);
throw new ToolError('errors.toolNotFound', { id: toolId });
}
return { id: newStepId(), toolId, values: {} };
}
@@ -31,17 +32,17 @@ export function parseDocument(raw: string): PipelineStep[] {
try {
parsed = JSON.parse(raw);
} catch {
throw new Error('Файл не является корректным JSON');
throw new ToolError('errors.badJson');
}
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 };
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)) {
throw new Error('В файле нет списка шагов');
throw new ToolError('errors.noSteps');
}
const steps: PipelineStep[] = [];
for (const rawStep of doc.steps) {
+2 -1
View File
@@ -1,6 +1,7 @@
<script lang="ts">
import { getTool } from '$lib/registry';
import { t } from '$lib/i18n/t';
import { toolTitle } from '$lib/i18n/tool-strings';
import ToolPage from '$lib/components/ToolPage.svelte';
import ToolSearch from '$lib/components/search/ToolSearch.svelte';
import Button from '$lib/components/ui/Button.svelte';
@@ -56,7 +57,7 @@
{@const restoreId = lastToolId}
<div class="restore-row">
<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>
</div>
{/if}
+3 -2
View File
@@ -1,6 +1,7 @@
<script lang="ts">
import { CATEGORIES } from '$lib/categories';
import { t } from '$lib/i18n/t';
import { toolDescription, toolTitle } from '$lib/i18n/tool-strings';
import ToolCard from '$lib/components/search/ToolCard.svelte';
import { TOOLS } from '$lib/registry';
</script>
@@ -23,8 +24,8 @@
<div class="panel">
<ToolCard
toolId={tool.id}
title={tool.title}
description={tool.description}
title={toolTitle(tool)}
description={toolDescription(tool)}
href="/tools/{tool.id}"
/>
</div>
+3 -2
View File
@@ -2,6 +2,7 @@
import ToolPage from '$lib/components/ToolPage.svelte';
import { getTool } from '$lib/registry';
import { t } from '$lib/i18n/t';
import { toolDescription, toolTitle } from '$lib/i18n/tool-strings';
let { data } = $props();
@@ -9,9 +10,9 @@
</script>
<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}
<meta name="description" content={tool.description} />
<meta name="description" content={toolDescription(tool)} />
{/if}
</svelte:head>