refactor: add error messages i18n
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
# План: i18n — русский и английский
|
# План: i18n — русский и английский
|
||||||
|
|
||||||
> Статус: план к выполнению.
|
> **СТАТУС: ВЫПОЛНЕН 25.08.2026.** Базовая локаль — английский (реестр), русский — перевод в словаре; строки инструментов в реестре переведены на английский в рамках этапа C.
|
||||||
|
|
||||||
## 1. Что получается
|
## 1. Что получается
|
||||||
|
|
||||||
+1
-19
@@ -1,21 +1,3 @@
|
|||||||
# Backlog
|
# Backlog
|
||||||
|
|
||||||
## 1. Несколько языков — оценка: L, разбита на подзадачи
|
(пусто — выполненные волны лежат в docs/archive/; кандидат на следующую волну: текст и водяные знаки — add-text, watermark, штампы, см. roadmap фаза 1.5 и план wave3 §7.)
|
||||||
|
|
||||||
### 1a. Инфраструктура i18n — M
|
|
||||||
|
|
||||||
Модуль словарей (`locales/ru.ts`, `locales/en.ts`), функция перевода с фолбэком на базовый язык, состояние активной локали (localStorage), переключатель в шапке, обновление `<html lang>`.
|
|
||||||
|
|
||||||
### 1b. Перевод каркаса интерфейса — S/M
|
|
||||||
|
|
||||||
Шапка, кнопки, подписи полей и состояний («Результат появится здесь», тексты ошибок исполнителя), главная-герой, страница каталога.
|
|
||||||
|
|
||||||
### 1c. Перевод контента реестра — L по объёму, механическая
|
|
||||||
|
|
||||||
Вынести title/description инструментов и label/options параметров в словари локалей с фолбэком. Объём: ~51 инструмент плюс параметры — основной трудозатратный кусок волны.
|
|
||||||
|
|
||||||
### 1d. Поиск по любому языку — M
|
|
||||||
|
|
||||||
Матчинг запроса против названий/описаний во всех поддерживаемых локалях сразу (не только активной); нормализация: нижний регистр, ё→е, снятие диакритики (NFD). Скоринг существующий сохраняется, лучшая локаль даёт балл.
|
|
||||||
|
|
||||||
Транслитерация ru↔en наизусть — не входит, отдельно при необходимости.
|
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { Snippet } from 'svelte';
|
import type { Snippet } from 'svelte';
|
||||||
import { isSupportedImage, unsupportedImageMessage } from '$lib/core/io';
|
import { isSupportedImage, unsupportedImageError } from '$lib/core/io';
|
||||||
import { t } from '$lib/i18n/t';
|
import { t } from '$lib/i18n/t';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
onFile: (file: File) => void;
|
onFile: (file: File) => void;
|
||||||
onError?: (message: string) => void;
|
onError?: (e: unknown) => void;
|
||||||
label?: string;
|
label?: string;
|
||||||
children: Snippet;
|
children: Snippet;
|
||||||
}
|
}
|
||||||
@@ -42,7 +42,7 @@
|
|||||||
const file = event.dataTransfer?.files[0];
|
const file = event.dataTransfer?.files[0];
|
||||||
if (!file) return;
|
if (!file) return;
|
||||||
if (!isSupportedImage(file)) {
|
if (!isSupportedImage(file)) {
|
||||||
onError?.(unsupportedImageMessage(file));
|
onError?.(unsupportedImageError(file));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
onFile(file);
|
onFile(file);
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { ACCEPTED_IMAGE_TYPES, isSupportedImage, unsupportedImageMessage } from '$lib/core/io';
|
import { ACCEPTED_IMAGE_TYPES, isSupportedImage, unsupportedImageError } from '$lib/core/io';
|
||||||
import { t } from '$lib/i18n/t';
|
import { t } from '$lib/i18n/t';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
onFile: (file: File) => void;
|
onFile: (file: File) => void;
|
||||||
onError?: (message: string) => void;
|
onError?: (e: unknown) => void;
|
||||||
label?: string;
|
label?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -33,7 +33,7 @@
|
|||||||
function accept(file: File | undefined | null) {
|
function accept(file: File | undefined | null) {
|
||||||
if (!file) return;
|
if (!file) return;
|
||||||
if (!isSupportedImage(file)) {
|
if (!isSupportedImage(file)) {
|
||||||
onError?.(unsupportedImageMessage(file));
|
onError?.(unsupportedImageError(file));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
onFile(file);
|
onFile(file);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { imageInfo, type ImageInfo } from '$lib/core/analyze';
|
import { imageInfo, type ImageInfo } from '$lib/core/analyze';
|
||||||
import { decodeFile, isSupportedImage, unsupportedImageMessage } from '$lib/core/io';
|
import { ToolError } from '$lib/core/errors';
|
||||||
|
import { decodeFile, isSupportedImage, unsupportedImageError } from '$lib/core/io';
|
||||||
import type { PixelImage } from '$lib/core/types';
|
import type { PixelImage } from '$lib/core/types';
|
||||||
import { defaultParams, getTool, outputOf, sanitizeParams, type ToolEntry } from '$lib/registry';
|
import { defaultParams, getTool, outputOf, sanitizeParams, type ToolEntry } from '$lib/registry';
|
||||||
import { loadStoredSteps, newStepId, saveSteps, type PipelineStep } from '$lib/tools/pipeline';
|
import { loadStoredSteps, newStepId, saveSteps, type PipelineStep } from '$lib/tools/pipeline';
|
||||||
@@ -177,9 +178,8 @@
|
|||||||
try {
|
try {
|
||||||
current = await executeStep(stepTool, current, sanitizeParams(stepTool, step.values));
|
current = await executeStep(stepTool, current, sanitizeParams(stepTool, step.values));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const rawMsg = e instanceof Error ? e.message : String(e);
|
|
||||||
throw new Error(
|
throw new Error(
|
||||||
t('toolPage.stepError', { n: i + 1, title: toolTitle(stepTool), msg: t(rawMsg) })
|
t('toolPage.stepError', { n: i + 1, title: toolTitle(stepTool), msg: errorMessage(e) })
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (!runner.isCurrent(token)) return;
|
if (!runner.isCurrent(token)) return;
|
||||||
@@ -215,10 +215,14 @@
|
|||||||
saveSteps(filled);
|
saveSteps(filled);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function errorMessage(e: unknown): string {
|
||||||
|
if (e instanceof ToolError) return t(e.key, e.vars);
|
||||||
|
return t(e instanceof Error ? e.message : String(e));
|
||||||
|
}
|
||||||
|
|
||||||
function showError(e: unknown) {
|
function showError(e: unknown) {
|
||||||
status = source ? 'loaded' : 'idle';
|
status = source ? 'loaded' : 'idle';
|
||||||
const raw = e instanceof Error ? e.message : String(e);
|
errorText = errorMessage(e);
|
||||||
errorText = t(raw);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function reset() {
|
function reset() {
|
||||||
@@ -243,7 +247,7 @@
|
|||||||
if (file) {
|
if (file) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
if (!isSupportedImage(file)) {
|
if (!isSupportedImage(file)) {
|
||||||
errorText = unsupportedImageMessage(file);
|
errorText = errorMessage(unsupportedImageError(file));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
handleFile(file);
|
handleFile(file);
|
||||||
@@ -274,7 +278,7 @@
|
|||||||
<SourceCard
|
<SourceCard
|
||||||
{source}
|
{source}
|
||||||
onFile={handleFile}
|
onFile={handleFile}
|
||||||
onError={(message) => (errorText = message)}
|
onError={(e) => (errorText = errorMessage(e))}
|
||||||
onReset={reset}
|
onReset={reset}
|
||||||
pipetteActive={!!pipetteTargetId}
|
pipetteActive={!!pipetteTargetId}
|
||||||
onPickColor={handlePickColor}
|
onPickColor={handlePickColor}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
interface Props {
|
interface Props {
|
||||||
source: PixelImage | null;
|
source: PixelImage | null;
|
||||||
onFile: (file: File) => void;
|
onFile: (file: File) => void;
|
||||||
onError: (message: string) => void;
|
onError: (e: unknown) => void;
|
||||||
onReset: () => void;
|
onReset: () => void;
|
||||||
pipetteActive?: boolean;
|
pipetteActive?: boolean;
|
||||||
onPickColor?: (hex: string) => void;
|
onPickColor?: (hex: string) => void;
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { parseHex } from './alpha';
|
import { parseHex } from './alpha';
|
||||||
|
import { ToolError } from './errors';
|
||||||
import { clonePixelImage, createPixelImage, type PixelImage } from './types';
|
import { clonePixelImage, createPixelImage, type PixelImage } from './types';
|
||||||
import { sampleBilinear } from './geometry';
|
import { sampleBilinear } from './geometry';
|
||||||
|
|
||||||
@@ -7,7 +8,7 @@ export type AffineMatrix = [number, number, number, number, number, number];
|
|||||||
export function invertAffine([a, b, c, d, e, f]: AffineMatrix): AffineMatrix {
|
export function invertAffine([a, b, c, d, e, f]: AffineMatrix): AffineMatrix {
|
||||||
const det = a * d - b * c;
|
const det = a * d - b * c;
|
||||||
if (Math.abs(det) < 1e-12) {
|
if (Math.abs(det) < 1e-12) {
|
||||||
throw new Error('Вырожденная матрица трансформации');
|
throw new ToolError('errors.badTransform');
|
||||||
}
|
}
|
||||||
const ia = d / det;
|
const ia = d / det;
|
||||||
const ib = -b / det;
|
const ib = -b / det;
|
||||||
@@ -91,7 +92,7 @@ export function skewImage(img: PixelImage, degX: number, degY: number): PixelIma
|
|||||||
const kx = Math.tan((degX * Math.PI) / 180);
|
const kx = Math.tan((degX * Math.PI) / 180);
|
||||||
const ky = Math.tan((degY * Math.PI) / 180);
|
const ky = Math.tan((degY * Math.PI) / 180);
|
||||||
if (!Number.isFinite(kx) || !Number.isFinite(ky)) {
|
if (!Number.isFinite(kx) || !Number.isFinite(ky)) {
|
||||||
throw new Error('Углы наклона не могут быть 90° или -90°');
|
throw new ToolError('errors.skewAngle');
|
||||||
}
|
}
|
||||||
return centeredTransform(img, [1, ky, kx, 1, 0, 0]);
|
return centeredTransform(img, [1, ky, kx, 1, 0, 0]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -181,6 +181,6 @@ describe('parseHex', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it.each(['zzz', '12345', '##ff', ''])('бросает ошибку на "%s"', (bad) => {
|
it.each(['zzz', '12345', '##ff', ''])('бросает ошибку на "%s"', (bad) => {
|
||||||
expect(() => parseHex(bad)).toThrow(/Некорректный HEX/);
|
expect(() => parseHex(bad)).toThrow(/errors\.badHex/);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { createPixelImage, type PixelImage } from './types';
|
import { createPixelImage, type PixelImage } from './types';
|
||||||
|
import { ToolError } from './errors';
|
||||||
|
|
||||||
const MAX_COLOR_DISTANCE = Math.sqrt(3 * 255 * 255);
|
const MAX_COLOR_DISTANCE = Math.sqrt(3 * 255 * 255);
|
||||||
|
|
||||||
@@ -120,7 +121,7 @@ export function flattenOntoColor(img: PixelImage, hex: string): PixelImage {
|
|||||||
export function parseHex(hex: string): [number, number, number] {
|
export function parseHex(hex: string): [number, number, number] {
|
||||||
const match = /^#?([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(hex.trim());
|
const match = /^#?([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(hex.trim());
|
||||||
if (!match) {
|
if (!match) {
|
||||||
throw new Error(`Некорректный HEX-цвет: "${hex}"`);
|
throw new ToolError('errors.badHex', { value: hex });
|
||||||
}
|
}
|
||||||
const digits = match[1];
|
const digits = match[1];
|
||||||
if (digits.length === 3) {
|
if (digits.length === 3) {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { clonePixelImage, createPixelImage, type PixelImage } from './types';
|
import { clonePixelImage, createPixelImage, type PixelImage } from './types';
|
||||||
|
import { ToolError } from './errors';
|
||||||
|
|
||||||
export type RgbChannel = 'red' | 'green' | 'blue';
|
export type RgbChannel = 'red' | 'green' | 'blue';
|
||||||
|
|
||||||
@@ -163,7 +164,7 @@ export function twoColors(
|
|||||||
function parseColor(hex: string): [number, number, number] {
|
function parseColor(hex: string): [number, number, number] {
|
||||||
const match = /^#([0-9a-f]{6})$/i.exec(hex.trim());
|
const match = /^#([0-9a-f]{6})$/i.exec(hex.trim());
|
||||||
if (!match) {
|
if (!match) {
|
||||||
throw new Error(`Некорректный HEX-цвет: "${hex}"`);
|
throw new ToolError('errors.badHex', { value: hex });
|
||||||
}
|
}
|
||||||
const digits = match[1];
|
const digits = match[1];
|
||||||
return [
|
return [
|
||||||
@@ -289,7 +290,7 @@ export function tint(
|
|||||||
): PixelImage {
|
): PixelImage {
|
||||||
const s = clamp(strengthPercent, 0, 100) / 100;
|
const s = clamp(strengthPercent, 0, 100) / 100;
|
||||||
const match = /^#([0-9a-f]{6})$/i.exec(colorHex.trim());
|
const match = /^#([0-9a-f]{6})$/i.exec(colorHex.trim());
|
||||||
if (!match) throw new Error(`Некорректный HEX-цвет: "${colorHex}"`);
|
if (!match) throw new ToolError('errors.badHex', { value: colorHex });
|
||||||
const d = match[1];
|
const d = match[1];
|
||||||
const tr = parseInt(d.slice(0, 2), 16) / 255;
|
const tr = parseInt(d.slice(0, 2), 16) / 255;
|
||||||
const tg = parseInt(d.slice(2, 4), 16) / 255;
|
const tg = parseInt(d.slice(2, 4), 16) / 255;
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { clonePixelImage, createPixelImage, type PixelImage } from './types';
|
import { clonePixelImage, createPixelImage, type PixelImage } from './types';
|
||||||
|
import { ToolError } from './errors';
|
||||||
|
|
||||||
type Plane = Float64Array;
|
type Plane = Float64Array;
|
||||||
|
|
||||||
@@ -8,10 +9,10 @@ export function convolve(
|
|||||||
size: number
|
size: number
|
||||||
): PixelImage {
|
): PixelImage {
|
||||||
if (!Number.isInteger(size) || size < 1 || size % 2 === 0) {
|
if (!Number.isInteger(size) || size < 1 || size % 2 === 0) {
|
||||||
throw new Error('Размер ядра должен быть нечётным положительным числом');
|
throw new ToolError('errors.radiusInt');
|
||||||
}
|
}
|
||||||
if (kernel.length !== size * size) {
|
if (kernel.length !== size * size) {
|
||||||
throw new Error('Длина ядра не совпадает с его размером');
|
throw new ToolError('errors.kernelSize');
|
||||||
}
|
}
|
||||||
const half = Math.floor(size / 2);
|
const half = Math.floor(size / 2);
|
||||||
const out = createPixelImage(img.width, img.height);
|
const out = createPixelImage(img.width, img.height);
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
export type ErrorVars = Record<string, string | number>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ошибка с стабильным ключом перевода вместо готового текста.
|
||||||
|
* Ядро бросает только её; человекочитаемый текст подставляет слой UI
|
||||||
|
* по секции errors активной локали.
|
||||||
|
*/
|
||||||
|
export class ToolError extends Error {
|
||||||
|
readonly key: string;
|
||||||
|
readonly vars?: ErrorVars;
|
||||||
|
|
||||||
|
constructor(key: string, vars?: ErrorVars) {
|
||||||
|
super(key);
|
||||||
|
this.name = 'ToolError';
|
||||||
|
this.key = key;
|
||||||
|
this.vars = vars;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { PixelImage } from './types';
|
import type { PixelImage } from './types';
|
||||||
|
import { ToolError } from './errors';
|
||||||
|
|
||||||
export function solidImage(
|
export function solidImage(
|
||||||
width: number,
|
width: number,
|
||||||
@@ -6,7 +7,7 @@ export function solidImage(
|
|||||||
rgba: [number, number, number, number]
|
rgba: [number, number, number, number]
|
||||||
): PixelImage {
|
): PixelImage {
|
||||||
if (!Number.isInteger(width) || !Number.isInteger(height) || width < 1 || height < 1) {
|
if (!Number.isInteger(width) || !Number.isInteger(height) || width < 1 || height < 1) {
|
||||||
throw new Error('Размеры должны быть целыми числами >= 1');
|
throw new ToolError('errors.sizeInt');
|
||||||
}
|
}
|
||||||
const data = new Uint8ClampedArray(width * height * 4);
|
const data = new Uint8ClampedArray(width * height * 4);
|
||||||
for (let i = 0; i < data.length; i += 4) {
|
for (let i = 0; i < data.length; i += 4) {
|
||||||
@@ -20,7 +21,7 @@ export function solidImage(
|
|||||||
|
|
||||||
export function noiseImage(width: number, height: number, seed: number): PixelImage {
|
export function noiseImage(width: number, height: number, seed: number): PixelImage {
|
||||||
if (!Number.isInteger(width) || !Number.isInteger(height) || width < 1 || height < 1) {
|
if (!Number.isInteger(width) || !Number.isInteger(height) || width < 1 || height < 1) {
|
||||||
throw new Error('Размеры должны быть целыми числами >= 1');
|
throw new ToolError('errors.sizeInt');
|
||||||
}
|
}
|
||||||
const random = mulberry32(seed);
|
const random = mulberry32(seed);
|
||||||
const data = new Uint8ClampedArray(width * height * 4);
|
const data = new Uint8ClampedArray(width * height * 4);
|
||||||
@@ -41,7 +42,7 @@ export function gradientImage(
|
|||||||
direction: 'horizontal' | 'vertical'
|
direction: 'horizontal' | 'vertical'
|
||||||
): PixelImage {
|
): PixelImage {
|
||||||
if (!Number.isInteger(width) || !Number.isInteger(height) || width < 1 || height < 1) {
|
if (!Number.isInteger(width) || !Number.isInteger(height) || width < 1 || height < 1) {
|
||||||
throw new Error('Размеры должны быть целыми числами >= 1');
|
throw new ToolError('errors.sizeInt');
|
||||||
}
|
}
|
||||||
const out: PixelImage = {
|
const out: PixelImage = {
|
||||||
width,
|
width,
|
||||||
|
|||||||
@@ -121,8 +121,8 @@ describe('crop', () => {
|
|||||||
expect([...out.data]).toEqual([1, 1, 1, 1]);
|
expect([...out.data]).toEqual([1, 1, 1, 1]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('бросает RangeError для области вне изображения', () => {
|
it('бросает ToolError для области вне изображения', () => {
|
||||||
expect(() => crop(grid(), 5, 5, 2, 2)).toThrow(RangeError);
|
expect(() => crop(grid(), 5, 5, 2, 2)).toThrow(/errors\.cropBounds/);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -207,9 +207,9 @@ describe('resize', () => {
|
|||||||
expect(red.slice(4, 8)).toEqual([50, 72, 117, 139]);
|
expect(red.slice(4, 8)).toEqual([50, 72, 117, 139]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('бросает RangeError на некорректные размеры', () => {
|
it('бросает ToolError на некорректные размеры', () => {
|
||||||
const img = twoByTwo();
|
const img = twoByTwo();
|
||||||
expect(() => resize(img, 0, 10)).toThrow(RangeError);
|
expect(() => resize(img, 0, 10)).toThrow(/errors\.sizeInt/);
|
||||||
expect(() => resize(img, 10.5, 10)).toThrow(RangeError);
|
expect(() => resize(img, 10.5, 10)).toThrow(/errors\.sizeInt/);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { parseHex } from './alpha';
|
import { parseHex } from './alpha';
|
||||||
|
import { ToolError } from './errors';
|
||||||
import { clonePixelImage, createPixelImage, type PixelImage } from './types';
|
import { clonePixelImage, createPixelImage, type PixelImage } from './types';
|
||||||
|
|
||||||
export function expandCanvas(
|
export function expandCanvas(
|
||||||
@@ -128,7 +129,7 @@ export function crop(
|
|||||||
const w = ex - sx;
|
const w = ex - sx;
|
||||||
const h = ey - sy;
|
const h = ey - sy;
|
||||||
if (w <= 0 || h <= 0) {
|
if (w <= 0 || h <= 0) {
|
||||||
throw new RangeError('Область обрезки пуста: она целиком вне изображения');
|
throw new ToolError('errors.cropBounds');
|
||||||
}
|
}
|
||||||
const out = createPixelImage(w, h);
|
const out = createPixelImage(w, h);
|
||||||
for (let row = 0; row < h; row++) {
|
for (let row = 0; row < h; row++) {
|
||||||
@@ -140,7 +141,7 @@ export function crop(
|
|||||||
|
|
||||||
export function resize(img: PixelImage, width: number, height: number): PixelImage {
|
export function resize(img: PixelImage, width: number, height: number): PixelImage {
|
||||||
if (!Number.isInteger(width) || !Number.isInteger(height) || width < 1 || height < 1) {
|
if (!Number.isInteger(width) || !Number.isInteger(height) || width < 1 || height < 1) {
|
||||||
throw new RangeError('Размеры должны быть целыми числами >= 1');
|
throw new ToolError('errors.sizeInt');
|
||||||
}
|
}
|
||||||
const out = createPixelImage(width, height);
|
const out = createPixelImage(width, height);
|
||||||
const xr = img.width / width;
|
const xr = img.width / width;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import { isSupportedImage, unsupportedImageMessage } from './io';
|
import { isSupportedImage, unsupportedImageError } from './io';
|
||||||
|
|
||||||
describe('isSupportedImage', () => {
|
describe('isSupportedImage', () => {
|
||||||
it.each(['image/png', 'image/jpeg', 'image/webp', 'image/gif', 'image/bmp', 'image/x-icon'])(
|
it.each(['image/png', 'image/jpeg', 'image/webp', 'image/gif', 'image/bmp', 'image/x-icon'])(
|
||||||
@@ -18,14 +18,15 @@ describe('isSupportedImage', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('unsupportedImageMessage', () => {
|
describe('unsupportedImageError', () => {
|
||||||
it('упоминает тип файла и поддерживаемые форматы', () => {
|
it('ключ ошибки и тип файла в vars', () => {
|
||||||
const message = unsupportedImageMessage(new File([], 'a.txt', { type: 'text/plain' }));
|
const err = unsupportedImageError(new File([], 'a.txt', { type: 'text/plain' }));
|
||||||
expect(message).toContain('text/plain');
|
expect(err.key).toBe('errors.unsupportedFile');
|
||||||
expect(message).toContain('PNG');
|
expect(err.vars?.type).toBe('text/plain');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('сообщает про неизвестный тип, когда он пуст', () => {
|
it('пустой тип передаётся как unknown', () => {
|
||||||
expect(unsupportedImageMessage(new File([], 'x'))).toContain('неизвестный');
|
const err = unsupportedImageError(new File([], 'x'));
|
||||||
|
expect(err.vars?.type).toBe('unknown');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+15
-11
@@ -1,4 +1,5 @@
|
|||||||
import { encodeBmpBytes } from './bmp';
|
import { encodeBmpBytes } from './bmp';
|
||||||
|
import { ToolError } from './errors';
|
||||||
import { type PixelImage } from './types';
|
import { type PixelImage } from './types';
|
||||||
|
|
||||||
export type OutputMime = 'image/png' | 'image/jpeg' | 'image/webp' | 'image/bmp';
|
export type OutputMime = 'image/png' | 'image/jpeg' | 'image/webp' | 'image/bmp';
|
||||||
@@ -12,8 +13,8 @@ export function isSupportedImage(file: File): boolean {
|
|||||||
return SUPPORTED_MIME_TYPES.has(file.type);
|
return SUPPORTED_MIME_TYPES.has(file.type);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function unsupportedImageMessage(file: File): string {
|
export function unsupportedImageError(file: File): ToolError {
|
||||||
return `Неподдерживаемый формат файла (${file.type || 'неизвестный'}). Поддерживаются PNG, JPEG, WebP, GIF и BMP.`;
|
return new ToolError('errors.unsupportedFile', { type: file.type || 'unknown' });
|
||||||
}
|
}
|
||||||
|
|
||||||
async function decodeBitmap(bitmap: ImageBitmap): Promise<PixelImage> {
|
async function decodeBitmap(bitmap: ImageBitmap): Promise<PixelImage> {
|
||||||
@@ -22,7 +23,7 @@ async function decodeBitmap(bitmap: ImageBitmap): Promise<PixelImage> {
|
|||||||
canvas.height = bitmap.height;
|
canvas.height = bitmap.height;
|
||||||
const ctx = canvas.getContext('2d', { willReadFrequently: true });
|
const ctx = canvas.getContext('2d', { willReadFrequently: true });
|
||||||
if (!ctx) {
|
if (!ctx) {
|
||||||
throw new Error('Canvas 2D context недоступен в этом браузере');
|
throw new ToolError('errors.noCanvasCtx');
|
||||||
}
|
}
|
||||||
ctx.drawImage(bitmap, 0, 0);
|
ctx.drawImage(bitmap, 0, 0);
|
||||||
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
|
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
|
||||||
@@ -54,7 +55,7 @@ export function toDataUrl(img: PixelImage): string {
|
|||||||
canvas.height = img.height;
|
canvas.height = img.height;
|
||||||
const ctx = canvas.getContext('2d');
|
const ctx = canvas.getContext('2d');
|
||||||
if (!ctx) {
|
if (!ctx) {
|
||||||
throw new Error('Canvas 2D context недоступен в этом браузере');
|
throw new ToolError('errors.noCanvasCtx');
|
||||||
}
|
}
|
||||||
ctx.putImageData(new ImageData(img.data, img.width, img.height), 0, 0);
|
ctx.putImageData(new ImageData(img.data, img.width, img.height), 0, 0);
|
||||||
return canvas.toDataURL('image/png');
|
return canvas.toDataURL('image/png');
|
||||||
@@ -67,7 +68,7 @@ export function toBase64(img: PixelImage): string {
|
|||||||
export async function decodeTextImage(text: string): Promise<PixelImage> {
|
export async function decodeTextImage(text: string): Promise<PixelImage> {
|
||||||
const cleaned = text.trim().replace(/^data:[^,]*,/, '');
|
const cleaned = text.trim().replace(/^data:[^,]*,/, '');
|
||||||
if (cleaned.length === 0) {
|
if (cleaned.length === 0) {
|
||||||
throw new Error('Вставьте base64-строку или data-uri изображения');
|
throw new ToolError('errors.badBase64');
|
||||||
}
|
}
|
||||||
const binary = atob(cleaned);
|
const binary = atob(cleaned);
|
||||||
const bytes = Uint8Array.from(binary, (ch) => ch.charCodeAt(0));
|
const bytes = Uint8Array.from(binary, (ch) => ch.charCodeAt(0));
|
||||||
@@ -84,7 +85,7 @@ export async function encode(
|
|||||||
}
|
}
|
||||||
if (mime === 'image/jpeg' || mime === 'image/webp') {
|
if (mime === 'image/jpeg' || mime === 'image/webp') {
|
||||||
if (quality !== undefined && (quality < 0 || quality > 1)) {
|
if (quality !== undefined && (quality < 0 || quality > 1)) {
|
||||||
throw new RangeError('quality должен быть в диапазоне 0..1');
|
throw new ToolError('errors.qualityRange');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const canvas = document.createElement('canvas');
|
const canvas = document.createElement('canvas');
|
||||||
@@ -92,7 +93,7 @@ export async function encode(
|
|||||||
canvas.height = img.height;
|
canvas.height = img.height;
|
||||||
const ctx = canvas.getContext('2d');
|
const ctx = canvas.getContext('2d');
|
||||||
if (!ctx) {
|
if (!ctx) {
|
||||||
throw new Error('Canvas 2D context недоступен в этом браузере');
|
throw new ToolError('errors.noCanvasCtx');
|
||||||
}
|
}
|
||||||
ctx.putImageData(new ImageData(img.data, img.width, img.height), 0, 0);
|
ctx.putImageData(new ImageData(img.data, img.width, img.height), 0, 0);
|
||||||
return await canvasToBlob(canvas, mime, quality);
|
return await canvasToBlob(canvas, mime, quality);
|
||||||
@@ -101,7 +102,10 @@ export async function encode(
|
|||||||
function canvasToBlob(canvas: HTMLCanvasElement, mime: OutputMime, quality?: number): Promise<Blob> {
|
function canvasToBlob(canvas: HTMLCanvasElement, mime: OutputMime, quality?: number): Promise<Blob> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
canvas.toBlob(
|
canvas.toBlob(
|
||||||
(blob) => (blob ? resolve(blob) : reject(new Error(`Браузер не поддерживает кодирование в ${mime}`))),
|
(blob) =>
|
||||||
|
blob
|
||||||
|
? resolve(blob)
|
||||||
|
: reject(new ToolError('errors.encodeUnsupported', { mime })),
|
||||||
mime,
|
mime,
|
||||||
quality
|
quality
|
||||||
);
|
);
|
||||||
@@ -127,7 +131,7 @@ export function replaceExtension(filename: string, ext: string): string {
|
|||||||
export async function decodeSvgText(text: string, targetWidth?: number): Promise<PixelImage> {
|
export async function decodeSvgText(text: string, targetWidth?: number): Promise<PixelImage> {
|
||||||
const trimmed = text.trim();
|
const trimmed = text.trim();
|
||||||
if (trimmed.length === 0) {
|
if (trimmed.length === 0) {
|
||||||
throw new Error('Вставьте разметку SVG');
|
throw new ToolError('errors.svgSize');
|
||||||
}
|
}
|
||||||
const blob = new Blob([trimmed], { type: 'image/svg+xml' });
|
const blob = new Blob([trimmed], { type: 'image/svg+xml' });
|
||||||
const url = URL.createObjectURL(blob);
|
const url = URL.createObjectURL(blob);
|
||||||
@@ -135,7 +139,7 @@ export async function decodeSvgText(text: string, targetWidth?: number): Promise
|
|||||||
const img = new Image();
|
const img = new Image();
|
||||||
await new Promise<void>((resolve, reject) => {
|
await new Promise<void>((resolve, reject) => {
|
||||||
img.onload = () => resolve();
|
img.onload = () => resolve();
|
||||||
img.onerror = () => reject(new Error('Не удалось загрузить SVG — проверьте разметку'));
|
img.onerror = () => reject(new ToolError('errors.svgLoad'));
|
||||||
img.src = url;
|
img.src = url;
|
||||||
});
|
});
|
||||||
const w = targetWidth ?? img.naturalWidth ?? 300;
|
const w = targetWidth ?? img.naturalWidth ?? 300;
|
||||||
@@ -145,7 +149,7 @@ export async function decodeSvgText(text: string, targetWidth?: number): Promise
|
|||||||
canvas.width = w;
|
canvas.width = w;
|
||||||
canvas.height = h;
|
canvas.height = h;
|
||||||
const ctx = canvas.getContext('2d', { willReadFrequently: true });
|
const ctx = canvas.getContext('2d', { willReadFrequently: true });
|
||||||
if (!ctx) throw new Error('Canvas 2D context недоступен в этом браузере');
|
if (!ctx) throw new ToolError('errors.noCanvasCtx');
|
||||||
ctx.drawImage(img, 0, 0, w, h);
|
ctx.drawImage(img, 0, 0, w, h);
|
||||||
const imageData = ctx.getImageData(0, 0, w, h);
|
const imageData = ctx.getImageData(0, 0, w, h);
|
||||||
return { width: imageData.width, height: imageData.height, data: imageData.data };
|
return { width: imageData.width, height: imageData.height, data: imageData.data };
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { PixelImage } from './types';
|
import type { PixelImage } from './types';
|
||||||
|
import { ToolError } from './errors';
|
||||||
|
|
||||||
export function pixelsToHex(img: PixelImage): string {
|
export function pixelsToHex(img: PixelImage): string {
|
||||||
const rows: string[] = [];
|
const rows: string[] = [];
|
||||||
@@ -15,20 +16,21 @@ export function pixelsToHex(img: PixelImage): string {
|
|||||||
|
|
||||||
export function hexToPixels(text: string, width: number): PixelImage {
|
export function hexToPixels(text: string, width: number): PixelImage {
|
||||||
if (!Number.isInteger(width) || width < 1) {
|
if (!Number.isInteger(width) || width < 1) {
|
||||||
throw new Error('Укажите ширину изображения (целое число >= 1)');
|
throw new ToolError('errors.widthInt');
|
||||||
}
|
}
|
||||||
const tokens = text.trim().split(/\s+/).filter((t) => t.length > 0);
|
const tokens = text.trim().split(/\s+/).filter((t) => t.length > 0);
|
||||||
if (tokens.length === 0) {
|
if (tokens.length === 0) {
|
||||||
throw new Error('Вставьте hex-данные пикселей');
|
throw new ToolError('errors.noHexPixels');
|
||||||
}
|
}
|
||||||
if (tokens.some((t) => !/^[0-9a-fA-F]{8}$/.test(t))) {
|
if (tokens.some((t) => !/^[0-9a-fA-F]{8}$/.test(t))) {
|
||||||
throw new Error('Каждый пиксель должен быть 8 hex-символов RRGGBBAA, разделённых пробелами');
|
throw new ToolError('errors.badPixelToken');
|
||||||
}
|
}
|
||||||
const height = tokens.length / width;
|
const height = tokens.length / width;
|
||||||
if (!Number.isInteger(height)) {
|
if (!Number.isInteger(height)) {
|
||||||
throw new Error(
|
throw new ToolError('errors.pixelCountMismatch', {
|
||||||
`Число пикселей (${tokens.length}) не делится на ширину ${width} без остатка`
|
count: tokens.length,
|
||||||
);
|
width
|
||||||
|
});
|
||||||
}
|
}
|
||||||
const data = new Uint8ClampedArray(tokens.length * 4);
|
const data = new Uint8ClampedArray(tokens.length * 4);
|
||||||
tokens.forEach((token, index) => {
|
tokens.forEach((token, index) => {
|
||||||
|
|||||||
+30
-1
@@ -108,7 +108,36 @@ export const en: Dict = {
|
|||||||
noImageRun: 'This tool does not process images',
|
noImageRun: 'This tool does not process images',
|
||||||
workerFailed: 'Worker execution failed',
|
workerFailed: 'Worker execution failed',
|
||||||
workerUnavailable: 'Worker is unavailable',
|
workerUnavailable: 'Worker is unavailable',
|
||||||
notFound: 'Tool not found'
|
notFound: 'Tool not found',
|
||||||
|
badTransform: 'Degenerate transformation matrix',
|
||||||
|
skewAngle: 'Skew angles cannot be 90° or -90°',
|
||||||
|
badHex: 'Invalid HEX color: "{value}"',
|
||||||
|
radiusInt: 'Radius must be a non-negative integer',
|
||||||
|
kernelSize: 'Kernel does not match the image dimensions',
|
||||||
|
sizeInt: 'Width and height must be integers ≥ 1',
|
||||||
|
cropBounds: 'Crop area does not intersect the image',
|
||||||
|
noCanvasCtx: 'Canvas 2D context is unavailable in this environment',
|
||||||
|
badBase64: 'Expected a base64 string or a data-uri of an image',
|
||||||
|
qualityRange: 'quality must be within 0..1',
|
||||||
|
svgSize: 'Could not determine SVG dimensions',
|
||||||
|
svgLoad: 'Failed to load SVG — check the markup',
|
||||||
|
encodeUnsupported: 'The browser does not support encoding to {mime}',
|
||||||
|
unsupportedFile:
|
||||||
|
'Unsupported file format ({type}). Supported formats are PNG, JPEG, WebP, GIF and BMP.',
|
||||||
|
widthInt: 'Image width must be an integer ≥ 1',
|
||||||
|
noHexPixels: 'No hex pixel values found',
|
||||||
|
badPixelToken: 'Each pixel must be 8 hex characters RRGGBBAA, separated by spaces',
|
||||||
|
pixelCountMismatch: 'Pixel count ({count}) is not divisible by width {width} without a remainder',
|
||||||
|
toolNotFound: 'Tool "{id}" not found',
|
||||||
|
badJson: 'The file is not valid JSON',
|
||||||
|
badPipelineShape: 'The file structure does not look like a chain of steps',
|
||||||
|
pipelineVersion: 'Unsupported chain version: {version}',
|
||||||
|
noSteps: 'The file has no list of steps',
|
||||||
|
paramNumber: 'Parameter "{id}" must be a number',
|
||||||
|
paramString: 'Parameter "{id}" must be a string',
|
||||||
|
resizeSize: 'Width and/or height must be positive',
|
||||||
|
cropSize: 'Crop width and height must be positive',
|
||||||
|
sizePositive: 'Dimensions must be positive and finite'
|
||||||
},
|
},
|
||||||
tools: {
|
tools: {
|
||||||
'png-is-grayscale': {
|
'png-is-grayscale': {
|
||||||
|
|||||||
+30
-1
@@ -108,7 +108,36 @@ export const ru: Dict = {
|
|||||||
noImageRun: 'Этот инструмент не обрабатывает изображения',
|
noImageRun: 'Этот инструмент не обрабатывает изображения',
|
||||||
workerFailed: 'Ошибка исполнения в воркере',
|
workerFailed: 'Ошибка исполнения в воркере',
|
||||||
workerUnavailable: 'Воркер недоступен',
|
workerUnavailable: 'Воркер недоступен',
|
||||||
notFound: 'Инструмент не найден'
|
notFound: 'Инструмент не найден',
|
||||||
|
badTransform: 'Вырожденная матрица трансформации',
|
||||||
|
skewAngle: 'Углы наклона не могут быть 90° или -90°',
|
||||||
|
badHex: 'Некорректный HEX-цвет: "{value}"',
|
||||||
|
radiusInt: 'Радиус должен быть целым неотрицательным числом',
|
||||||
|
kernelSize: 'Ядро не совпадает с изображением по размеру',
|
||||||
|
sizeInt: 'Ширина и высота должны быть целыми числами ≥ 1',
|
||||||
|
cropBounds: 'Область обрезки не пересекает изображение',
|
||||||
|
noCanvasCtx: 'Canvas 2D context недоступен в этом окружении',
|
||||||
|
badBase64: 'Ожидается base64-строка или data-uri изображения',
|
||||||
|
qualityRange: 'quality должно быть в диапазоне 0..1',
|
||||||
|
svgSize: 'Не удалось определить размер SVG',
|
||||||
|
svgLoad: 'Не удалось загрузить SVG — проверьте разметку',
|
||||||
|
encodeUnsupported: 'Браузер не поддерживает кодирование в {mime}',
|
||||||
|
unsupportedFile:
|
||||||
|
'Неподдерживаемый формат файла ({type}). Поддерживаются PNG, JPEG, WebP, GIF и BMP.',
|
||||||
|
widthInt: 'Ширина изображения должна быть целым числом ≥ 1',
|
||||||
|
noHexPixels: 'Не найдено hex-значений пикселей',
|
||||||
|
badPixelToken: 'Каждый пиксель — 8 hex-символов RRGGBBAA, значения через пробел',
|
||||||
|
pixelCountMismatch: 'Число пикселей ({count}) не делится на ширину {width} без остатка',
|
||||||
|
toolNotFound: 'Инструмент "{id}" не найден',
|
||||||
|
badJson: 'Файл не является корректным JSON',
|
||||||
|
badPipelineShape: 'Структура файла не похожа на цепочку шагов',
|
||||||
|
pipelineVersion: 'Неподдерживаемая версия цепочки: {version}',
|
||||||
|
noSteps: 'В файле нет списка шагов',
|
||||||
|
paramNumber: 'Параметр "{id}" должен быть числом',
|
||||||
|
paramString: 'Параметр "{id}" должен быть строкой',
|
||||||
|
resizeSize: 'Ширина и/или высота должны быть положительными',
|
||||||
|
cropSize: 'Ширина и высота области обрезки должны быть положительными',
|
||||||
|
sizePositive: 'Размеры должны быть положительными и конечными'
|
||||||
},
|
},
|
||||||
tools: {
|
tools: {
|
||||||
'jpg-to-png': {
|
'jpg-to-png': {
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -170,7 +170,7 @@ describe('run инструмента resize-png', () => {
|
|||||||
|
|
||||||
it('обе стороны 0 — человекочитаемая ошибка', async () => {
|
it('обе стороны 0 — человекочитаемая ошибка', async () => {
|
||||||
await expect(runResize({ width: 0, height: 0, keepAspect: true })).rejects.toThrow(
|
await expect(runResize({ width: 0, height: 0, keepAspect: true })).rejects.toThrow(
|
||||||
'Укажите ширину'
|
'errors.resizeSize'
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { CategoryId } from './categories';
|
import type { CategoryId } from './categories';
|
||||||
|
import { ToolError } from './core/errors';
|
||||||
import {
|
import {
|
||||||
colorMask,
|
colorMask,
|
||||||
extractAlphaMask,
|
extractAlphaMask,
|
||||||
@@ -121,7 +122,7 @@ export function isChainable(tool: ToolEntry): boolean {
|
|||||||
function num(params: Record<string, unknown>, id: string): number {
|
function num(params: Record<string, unknown>, id: string): number {
|
||||||
const v = params[id];
|
const v = params[id];
|
||||||
if (typeof v !== 'number' || !Number.isFinite(v)) {
|
if (typeof v !== 'number' || !Number.isFinite(v)) {
|
||||||
throw new Error(`Параметр "${id}" должен быть числом`);
|
throw new ToolError('errors.paramNumber', { id });
|
||||||
}
|
}
|
||||||
return v;
|
return v;
|
||||||
}
|
}
|
||||||
@@ -129,7 +130,7 @@ function num(params: Record<string, unknown>, id: string): number {
|
|||||||
function str(params: Record<string, unknown>, id: string): string {
|
function str(params: Record<string, unknown>, id: string): string {
|
||||||
const v = params[id];
|
const v = params[id];
|
||||||
if (typeof v !== 'string') {
|
if (typeof v !== 'string') {
|
||||||
throw new Error(`Параметр "${id}" должен быть строкой`);
|
throw new ToolError('errors.paramString', { id });
|
||||||
}
|
}
|
||||||
return v;
|
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] {
|
function hexToRgba(hex: string, alpha = 255): [number, number, number, number] {
|
||||||
const match = /^#([0-9a-f]{6})$/i.exec(hex.trim());
|
const match = /^#([0-9a-f]{6})$/i.exec(hex.trim());
|
||||||
if (!match) {
|
if (!match) {
|
||||||
throw new Error(`Некорректный HEX-цвет: "${hex}"`);
|
throw new ToolError('errors.badHex', { value: hex });
|
||||||
}
|
}
|
||||||
const d = match[1];
|
const d = match[1];
|
||||||
return [
|
return [
|
||||||
@@ -282,7 +283,7 @@ export const TOOLS: ToolEntry[] = [
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (w <= 0 || h <= 0) {
|
if (w <= 0 || h <= 0) {
|
||||||
throw new Error('Укажите ширину и/или высоту нового размера');
|
throw new ToolError('errors.resizeSize');
|
||||||
}
|
}
|
||||||
return resize(img, w, h);
|
return resize(img, w, h);
|
||||||
}
|
}
|
||||||
@@ -303,7 +304,7 @@ export const TOOLS: ToolEntry[] = [
|
|||||||
const w = Math.trunc(num(p, 'width'));
|
const w = Math.trunc(num(p, 'width'));
|
||||||
const h = Math.trunc(num(p, 'height'));
|
const h = Math.trunc(num(p, 'height'));
|
||||||
if (w <= 0 || h <= 0) {
|
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);
|
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 width = Math.trunc(num(p, 'width'));
|
||||||
const height = Math.trunc(num(p, 'height'));
|
const height = Math.trunc(num(p, 'height'));
|
||||||
if (width <= 0 || height <= 0) {
|
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 left = Math.max(0, Math.floor((width - img.width) / 2));
|
||||||
const top = Math.max(0, Math.floor((height - img.height) / 2));
|
const top = Math.max(0, Math.floor((height - img.height) / 2));
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { PixelImage } from '../core/types';
|
import type { PixelImage } from '../core/types';
|
||||||
|
import { ToolError } from '../core/errors';
|
||||||
|
|
||||||
type MaybeRunnable = {
|
type MaybeRunnable = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -63,6 +64,8 @@ function ensureWorker(): Worker | null {
|
|||||||
height?: number;
|
height?: number;
|
||||||
data?: Uint8ClampedArray;
|
data?: Uint8ClampedArray;
|
||||||
error?: string;
|
error?: string;
|
||||||
|
errorKey?: string;
|
||||||
|
errorVars?: Record<string, string | number>;
|
||||||
};
|
};
|
||||||
const entry = pending.get(payload.id);
|
const entry = pending.get(payload.id);
|
||||||
if (!entry) return;
|
if (!entry) return;
|
||||||
@@ -73,6 +76,8 @@ function ensureWorker(): Worker | null {
|
|||||||
height: payload.height,
|
height: payload.height,
|
||||||
data: new Uint8ClampedArray(payload.data)
|
data: new Uint8ClampedArray(payload.data)
|
||||||
});
|
});
|
||||||
|
} else if (payload.errorKey) {
|
||||||
|
entry.reject(new ToolError(payload.errorKey, payload.errorVars));
|
||||||
} else {
|
} else {
|
||||||
entry.reject(new Error(payload.error ?? 'errors.workerFailed'));
|
entry.reject(new Error(payload.error ?? 'errors.workerFailed'));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
/// <reference lib="webworker" />
|
/// <reference lib="webworker" />
|
||||||
import type { PixelImage } from '../core/types';
|
import type { PixelImage } from '../core/types';
|
||||||
|
import { ToolError } from '../core/errors';
|
||||||
import { getTool, sanitizeParams } from '../registry';
|
import { getTool, sanitizeParams } from '../registry';
|
||||||
|
|
||||||
type WorkerRequest = {
|
type WorkerRequest = {
|
||||||
@@ -34,10 +35,13 @@ async function handle(request: WorkerRequest): Promise<void> {
|
|||||||
};
|
};
|
||||||
(self as unknown as Worker).postMessage(payload, [output.data.buffer]);
|
(self as unknown as Worker).postMessage(payload, [output.data.buffer]);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
const toolError = e instanceof ToolError ? e : undefined;
|
||||||
(self as unknown as Worker).postMessage({
|
(self as unknown as Worker).postMessage({
|
||||||
id: request.id,
|
id: request.id,
|
||||||
ok: false,
|
ok: false,
|
||||||
error: e instanceof Error ? e.message : String(e)
|
error: e instanceof Error ? e.message : String(e),
|
||||||
|
errorKey: toolError?.key,
|
||||||
|
errorVars: toolError?.vars
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ describe('createStep', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('бросает ошибку для неизвестного инструмента', () => {
|
it('бросает ошибку для неизвестного инструмента', () => {
|
||||||
expect(() => createStep('no-such-tool')).toThrow(/не найден/);
|
expect(() => createStep('no-such-tool')).toThrow(/errors\.toolNotFound/);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { getTool, sanitizeParams } from '../registry';
|
import { getTool, sanitizeParams } from '../registry';
|
||||||
|
import { ToolError } from '../core/errors';
|
||||||
|
|
||||||
export type PipelineStep = {
|
export type PipelineStep = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -14,7 +15,7 @@ const STORAGE_KEY = 'workspace-pipeline';
|
|||||||
export function createStep(toolId: string): PipelineStep {
|
export function createStep(toolId: string): PipelineStep {
|
||||||
const tool = getTool(toolId);
|
const tool = getTool(toolId);
|
||||||
if (!tool) {
|
if (!tool) {
|
||||||
throw new Error(`Инструмент "${toolId}" не найден`);
|
throw new ToolError('errors.toolNotFound', { id: toolId });
|
||||||
}
|
}
|
||||||
return { id: newStepId(), toolId, values: {} };
|
return { id: newStepId(), toolId, values: {} };
|
||||||
}
|
}
|
||||||
@@ -31,17 +32,17 @@ export function parseDocument(raw: string): PipelineStep[] {
|
|||||||
try {
|
try {
|
||||||
parsed = JSON.parse(raw);
|
parsed = JSON.parse(raw);
|
||||||
} catch {
|
} catch {
|
||||||
throw new Error('Файл не является корректным JSON');
|
throw new ToolError('errors.badJson');
|
||||||
}
|
}
|
||||||
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
|
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
|
||||||
throw new Error('Структура файла не похожа на цепочку шагов');
|
throw new ToolError('errors.badPipelineShape');
|
||||||
}
|
}
|
||||||
const doc = parsed as { version?: unknown; steps?: unknown };
|
const doc = parsed as { version?: unknown; steps?: unknown };
|
||||||
if (doc.version !== PIPELINE_VERSION) {
|
if (doc.version !== PIPELINE_VERSION) {
|
||||||
throw new Error(`Неподдерживаемая версия цепочки: ${String(doc.version)}`);
|
throw new ToolError('errors.pipelineVersion', { version: String(doc.version) });
|
||||||
}
|
}
|
||||||
if (!Array.isArray(doc.steps)) {
|
if (!Array.isArray(doc.steps)) {
|
||||||
throw new Error('В файле нет списка шагов');
|
throw new ToolError('errors.noSteps');
|
||||||
}
|
}
|
||||||
const steps: PipelineStep[] = [];
|
const steps: PipelineStep[] = [];
|
||||||
for (const rawStep of doc.steps) {
|
for (const rawStep of doc.steps) {
|
||||||
|
|||||||
Reference in New Issue
Block a user