refactor: add error messages i18n

This commit is contained in:
2026-08-25 09:49:15 +05:00
parent c04a1fc1dc
commit 486b99dbb8
27 changed files with 262 additions and 94 deletions
@@ -1,6 +1,6 @@
# План: i18n — русский и английский
> Статус: план к выполнению.
> **СТАТУС: ВЫПОЛНЕН 25.08.2026.** Базовая локаль — английский (реестр), русский — перевод в словаре; строки инструментов в реестре переведены на английский в рамках этапа C.
## 1. Что получается
+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.)
+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);
+11 -7
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';
@@ -177,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: toolTitle(stepTool), msg: t(rawMsg) })
t('toolPage.stepError', { n: i + 1, title: toolTitle(stepTool), msg: errorMessage(e) })
);
}
if (!runner.isCurrent(token)) return;
@@ -215,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() {
@@ -243,7 +247,7 @@
if (file) {
event.preventDefault();
if (!isSupportedImage(file)) {
errorText = unsupportedImageMessage(file);
errorText = errorMessage(unsupportedImageError(file));
return;
}
handleFile(file);
@@ -274,7 +278,7 @@
<SourceCard
{source}
onFile={handleFile}
onError={(message) => (errorText = message)}
onError={(e) => (errorText = errorMessage(e))}
onReset={reset}
pipetteActive={!!pipetteTargetId}
onPickColor={handlePickColor}
@@ -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;
+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');
});
});
+15 -11
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;
@@ -145,7 +149,7 @@ export async function decodeSvgText(text: string, targetWidth?: number): Promise
canvas.width = w;
canvas.height = h;
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);
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) => {
+30 -1
View File
@@ -108,7 +108,36 @@ 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: {
'png-is-grayscale': {
+30 -1
View File
@@ -108,7 +108,36 @@ 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: {
'jpg-to-png': {
+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');
});
});
+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'
);
});
});
+7 -6
View File
@@ -1,4 +1,5 @@
import type { CategoryId } from './categories';
import { ToolError } from './core/errors';
import {
colorMask,
extractAlphaMask,
@@ -121,7 +122,7 @@ export function isChainable(tool: ToolEntry): boolean {
function num(params: Record<string, unknown>, id: string): number {
const v = params[id];
if (typeof v !== 'number' || !Number.isFinite(v)) {
throw new Error(`Параметр "${id}" должен быть числом`);
throw new ToolError('errors.paramNumber', { id });
}
return v;
}
@@ -129,7 +130,7 @@ function num(params: Record<string, unknown>, id: string): number {
function str(params: Record<string, unknown>, id: string): string {
const v = params[id];
if (typeof v !== 'string') {
throw new Error(`Параметр "${id}" должен быть строкой`);
throw new ToolError('errors.paramString', { id });
}
return v;
}
@@ -148,7 +149,7 @@ function decodeToPng(id: string, title: string, description: string): ToolEntry
function hexToRgba(hex: string, alpha = 255): [number, 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 d = match[1];
return [
@@ -282,7 +283,7 @@ export const TOOLS: ToolEntry[] = [
}
}
if (w <= 0 || h <= 0) {
throw new Error('Укажите ширину и/или высоту нового размера');
throw new ToolError('errors.resizeSize');
}
return resize(img, w, h);
}
@@ -303,7 +304,7 @@ export const TOOLS: ToolEntry[] = [
const w = Math.trunc(num(p, 'width'));
const h = Math.trunc(num(p, 'height'));
if (w <= 0 || h <= 0) {
throw new Error('Укажите ширину и высоту области обрезки');
throw new ToolError('errors.cropSize');
}
return crop(img, Math.trunc(num(p, 'x')), Math.trunc(num(p, 'y')), w, h);
}
@@ -394,7 +395,7 @@ export const TOOLS: ToolEntry[] = [
const width = Math.trunc(num(p, 'width'));
const height = Math.trunc(num(p, 'height'));
if (width <= 0 || height <= 0) {
throw new Error('Укажите положительные размеры полотна');
throw new ToolError('errors.sizePositive');
}
const left = Math.max(0, Math.floor((width - img.width) / 2));
const top = Math.max(0, Math.floor((height - img.height) / 2));
+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) {