mirror of
https://github.com/Ku6epXBOCTuK/easy-png-tools.git
synced 2026-09-14 13:36:36 +00:00
refactor: add error messages i18n
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
# План: i18n — русский и английский
|
||||
|
||||
> Статус: план к выполнению.
|
||||
> **СТАТУС: ВЫПОЛНЕН 25.08.2026.** Базовая локаль — английский (реестр), русский — перевод в словаре; строки инструментов в реестре переведены на английский в рамках этапа C.
|
||||
|
||||
## 1. Что получается
|
||||
|
||||
+1
-19
@@ -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,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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
|
||||
@@ -181,6 +181,6 @@ describe('parseHex', () => {
|
||||
});
|
||||
|
||||
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 { 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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 { 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,
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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 };
|
||||
|
||||
@@ -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
@@ -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
@@ -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': {
|
||||
|
||||
@@ -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 () => {
|
||||
await expect(runResize({ width: 0, height: 0, keepAspect: true })).rejects.toThrow(
|
||||
'Укажите ширину'
|
||||
'errors.resizeSize'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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'));
|
||||
}
|
||||
|
||||
@@ -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
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ describe('createStep', () => {
|
||||
});
|
||||
|
||||
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 { 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) {
|
||||
|
||||
Reference in New Issue
Block a user