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
+3 -2
View File
@@ -1,4 +1,5 @@
import { parseHex } from './alpha';
import { ToolError } from './errors';
import { clonePixelImage, createPixelImage, type PixelImage } from './types';
import { sampleBilinear } from './geometry';
@@ -7,7 +8,7 @@ export type AffineMatrix = [number, number, number, number, number, number];
export function invertAffine([a, b, c, d, e, f]: AffineMatrix): AffineMatrix {
const det = a * d - b * c;
if (Math.abs(det) < 1e-12) {
throw new Error('Вырожденная матрица трансформации');
throw new ToolError('errors.badTransform');
}
const ia = d / det;
const ib = -b / det;
@@ -91,7 +92,7 @@ export function skewImage(img: PixelImage, degX: number, degY: number): PixelIma
const kx = Math.tan((degX * Math.PI) / 180);
const ky = Math.tan((degY * Math.PI) / 180);
if (!Number.isFinite(kx) || !Number.isFinite(ky)) {
throw new Error('Углы наклона не могут быть 90° или -90°');
throw new ToolError('errors.skewAngle');
}
return centeredTransform(img, [1, ky, kx, 1, 0, 0]);
}
+1 -1
View File
@@ -181,6 +181,6 @@ describe('parseHex', () => {
});
it.each(['zzz', '12345', '##ff', ''])('бросает ошибку на "%s"', (bad) => {
expect(() => parseHex(bad)).toThrow(/Некорректный HEX/);
expect(() => parseHex(bad)).toThrow(/errors\.badHex/);
});
});
+2 -1
View File
@@ -1,4 +1,5 @@
import { createPixelImage, type PixelImage } from './types';
import { ToolError } from './errors';
const MAX_COLOR_DISTANCE = Math.sqrt(3 * 255 * 255);
@@ -120,7 +121,7 @@ export function flattenOntoColor(img: PixelImage, hex: string): PixelImage {
export function parseHex(hex: string): [number, number, number] {
const match = /^#?([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(hex.trim());
if (!match) {
throw new Error(`Некорректный HEX-цвет: "${hex}"`);
throw new ToolError('errors.badHex', { value: hex });
}
const digits = match[1];
if (digits.length === 3) {
+3 -2
View File
@@ -1,4 +1,5 @@
import { clonePixelImage, createPixelImage, type PixelImage } from './types';
import { ToolError } from './errors';
export type RgbChannel = 'red' | 'green' | 'blue';
@@ -163,7 +164,7 @@ export function twoColors(
function parseColor(hex: string): [number, number, number] {
const match = /^#([0-9a-f]{6})$/i.exec(hex.trim());
if (!match) {
throw new Error(`Некорректный HEX-цвет: "${hex}"`);
throw new ToolError('errors.badHex', { value: hex });
}
const digits = match[1];
return [
@@ -289,7 +290,7 @@ export function tint(
): PixelImage {
const s = clamp(strengthPercent, 0, 100) / 100;
const match = /^#([0-9a-f]{6})$/i.exec(colorHex.trim());
if (!match) throw new Error(`Некорректный HEX-цвет: "${colorHex}"`);
if (!match) throw new ToolError('errors.badHex', { value: colorHex });
const d = match[1];
const tr = parseInt(d.slice(0, 2), 16) / 255;
const tg = parseInt(d.slice(2, 4), 16) / 255;
+3 -2
View File
@@ -1,4 +1,5 @@
import { clonePixelImage, createPixelImage, type PixelImage } from './types';
import { ToolError } from './errors';
type Plane = Float64Array;
@@ -8,10 +9,10 @@ export function convolve(
size: number
): PixelImage {
if (!Number.isInteger(size) || size < 1 || size % 2 === 0) {
throw new Error('Размер ядра должен быть нечётным положительным числом');
throw new ToolError('errors.radiusInt');
}
if (kernel.length !== size * size) {
throw new Error('Длина ядра не совпадает с его размером');
throw new ToolError('errors.kernelSize');
}
const half = Math.floor(size / 2);
const out = createPixelImage(img.width, img.height);
+18
View File
@@ -0,0 +1,18 @@
export type ErrorVars = Record<string, string | number>;
/**
* Ошибка с стабильным ключом перевода вместо готового текста.
* Ядро бросает только её; человекочитаемый текст подставляет слой UI
* по секции errors активной локали.
*/
export class ToolError extends Error {
readonly key: string;
readonly vars?: ErrorVars;
constructor(key: string, vars?: ErrorVars) {
super(key);
this.name = 'ToolError';
this.key = key;
this.vars = vars;
}
}
+4 -3
View File
@@ -1,4 +1,5 @@
import type { PixelImage } from './types';
import { ToolError } from './errors';
export function solidImage(
width: number,
@@ -6,7 +7,7 @@ export function solidImage(
rgba: [number, number, number, number]
): PixelImage {
if (!Number.isInteger(width) || !Number.isInteger(height) || width < 1 || height < 1) {
throw new Error('Размеры должны быть целыми числами >= 1');
throw new ToolError('errors.sizeInt');
}
const data = new Uint8ClampedArray(width * height * 4);
for (let i = 0; i < data.length; i += 4) {
@@ -20,7 +21,7 @@ export function solidImage(
export function noiseImage(width: number, height: number, seed: number): PixelImage {
if (!Number.isInteger(width) || !Number.isInteger(height) || width < 1 || height < 1) {
throw new Error('Размеры должны быть целыми числами >= 1');
throw new ToolError('errors.sizeInt');
}
const random = mulberry32(seed);
const data = new Uint8ClampedArray(width * height * 4);
@@ -41,7 +42,7 @@ export function gradientImage(
direction: 'horizontal' | 'vertical'
): PixelImage {
if (!Number.isInteger(width) || !Number.isInteger(height) || width < 1 || height < 1) {
throw new Error('Размеры должны быть целыми числами >= 1');
throw new ToolError('errors.sizeInt');
}
const out: PixelImage = {
width,
+5 -5
View File
@@ -121,8 +121,8 @@ describe('crop', () => {
expect([...out.data]).toEqual([1, 1, 1, 1]);
});
it('бросает RangeError для области вне изображения', () => {
expect(() => crop(grid(), 5, 5, 2, 2)).toThrow(RangeError);
it('бросает ToolError для области вне изображения', () => {
expect(() => crop(grid(), 5, 5, 2, 2)).toThrow(/errors\.cropBounds/);
});
});
@@ -207,9 +207,9 @@ describe('resize', () => {
expect(red.slice(4, 8)).toEqual([50, 72, 117, 139]);
});
it('бросает RangeError на некорректные размеры', () => {
it('бросает ToolError на некорректные размеры', () => {
const img = twoByTwo();
expect(() => resize(img, 0, 10)).toThrow(RangeError);
expect(() => resize(img, 10.5, 10)).toThrow(RangeError);
expect(() => resize(img, 0, 10)).toThrow(/errors\.sizeInt/);
expect(() => resize(img, 10.5, 10)).toThrow(/errors\.sizeInt/);
});
});
+3 -2
View File
@@ -1,4 +1,5 @@
import { parseHex } from './alpha';
import { ToolError } from './errors';
import { clonePixelImage, createPixelImage, type PixelImage } from './types';
export function expandCanvas(
@@ -128,7 +129,7 @@ export function crop(
const w = ex - sx;
const h = ey - sy;
if (w <= 0 || h <= 0) {
throw new RangeError('Область обрезки пуста: она целиком вне изображения');
throw new ToolError('errors.cropBounds');
}
const out = createPixelImage(w, h);
for (let row = 0; row < h; row++) {
@@ -140,7 +141,7 @@ export function crop(
export function resize(img: PixelImage, width: number, height: number): PixelImage {
if (!Number.isInteger(width) || !Number.isInteger(height) || width < 1 || height < 1) {
throw new RangeError('Размеры должны быть целыми числами >= 1');
throw new ToolError('errors.sizeInt');
}
const out = createPixelImage(width, height);
const xr = img.width / width;
+9 -8
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest';
import { isSupportedImage, unsupportedImageMessage } from './io';
import { isSupportedImage, unsupportedImageError } from './io';
describe('isSupportedImage', () => {
it.each(['image/png', 'image/jpeg', 'image/webp', 'image/gif', 'image/bmp', 'image/x-icon'])(
@@ -18,14 +18,15 @@ describe('isSupportedImage', () => {
});
});
describe('unsupportedImageMessage', () => {
it('упоминает тип файла и поддерживаемые форматы', () => {
const message = unsupportedImageMessage(new File([], 'a.txt', { type: 'text/plain' }));
expect(message).toContain('text/plain');
expect(message).toContain('PNG');
describe('unsupportedImageError', () => {
it('ключ ошибки и тип файла в vars', () => {
const err = unsupportedImageError(new File([], 'a.txt', { type: 'text/plain' }));
expect(err.key).toBe('errors.unsupportedFile');
expect(err.vars?.type).toBe('text/plain');
});
it('сообщает про неизвестный тип, когда он пуст', () => {
expect(unsupportedImageMessage(new File([], 'x'))).toContain('неизвестный');
it('пустой тип передаётся как unknown', () => {
const err = unsupportedImageError(new File([], 'x'));
expect(err.vars?.type).toBe('unknown');
});
});
+16 -12
View File
@@ -1,4 +1,5 @@
import { encodeBmpBytes } from './bmp';
import { ToolError } from './errors';
import { type PixelImage } from './types';
export type OutputMime = 'image/png' | 'image/jpeg' | 'image/webp' | 'image/bmp';
@@ -12,8 +13,8 @@ export function isSupportedImage(file: File): boolean {
return SUPPORTED_MIME_TYPES.has(file.type);
}
export function unsupportedImageMessage(file: File): string {
return `Неподдерживаемый формат файла (${file.type || 'неизвестный'}). Поддерживаются PNG, JPEG, WebP, GIF и BMP.`;
export function unsupportedImageError(file: File): ToolError {
return new ToolError('errors.unsupportedFile', { type: file.type || 'unknown' });
}
async function decodeBitmap(bitmap: ImageBitmap): Promise<PixelImage> {
@@ -22,7 +23,7 @@ async function decodeBitmap(bitmap: ImageBitmap): Promise<PixelImage> {
canvas.height = bitmap.height;
const ctx = canvas.getContext('2d', { willReadFrequently: true });
if (!ctx) {
throw new Error('Canvas 2D context недоступен в этом браузере');
throw new ToolError('errors.noCanvasCtx');
}
ctx.drawImage(bitmap, 0, 0);
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
@@ -54,7 +55,7 @@ export function toDataUrl(img: PixelImage): string {
canvas.height = img.height;
const ctx = canvas.getContext('2d');
if (!ctx) {
throw new Error('Canvas 2D context недоступен в этом браузере');
throw new ToolError('errors.noCanvasCtx');
}
ctx.putImageData(new ImageData(img.data, img.width, img.height), 0, 0);
return canvas.toDataURL('image/png');
@@ -67,7 +68,7 @@ export function toBase64(img: PixelImage): string {
export async function decodeTextImage(text: string): Promise<PixelImage> {
const cleaned = text.trim().replace(/^data:[^,]*,/, '');
if (cleaned.length === 0) {
throw new Error('Вставьте base64-строку или data-uri изображения');
throw new ToolError('errors.badBase64');
}
const binary = atob(cleaned);
const bytes = Uint8Array.from(binary, (ch) => ch.charCodeAt(0));
@@ -84,7 +85,7 @@ export async function encode(
}
if (mime === 'image/jpeg' || mime === 'image/webp') {
if (quality !== undefined && (quality < 0 || quality > 1)) {
throw new RangeError('quality должен быть в диапазоне 0..1');
throw new ToolError('errors.qualityRange');
}
}
const canvas = document.createElement('canvas');
@@ -92,7 +93,7 @@ export async function encode(
canvas.height = img.height;
const ctx = canvas.getContext('2d');
if (!ctx) {
throw new Error('Canvas 2D context недоступен в этом браузере');
throw new ToolError('errors.noCanvasCtx');
}
ctx.putImageData(new ImageData(img.data, img.width, img.height), 0, 0);
return await canvasToBlob(canvas, mime, quality);
@@ -101,7 +102,10 @@ export async function encode(
function canvasToBlob(canvas: HTMLCanvasElement, mime: OutputMime, quality?: number): Promise<Blob> {
return new Promise((resolve, reject) => {
canvas.toBlob(
(blob) => (blob ? resolve(blob) : reject(new Error(`Браузер не поддерживает кодирование в ${mime}`))),
(blob) =>
blob
? resolve(blob)
: reject(new ToolError('errors.encodeUnsupported', { mime })),
mime,
quality
);
@@ -127,7 +131,7 @@ export function replaceExtension(filename: string, ext: string): string {
export async function decodeSvgText(text: string, targetWidth?: number): Promise<PixelImage> {
const trimmed = text.trim();
if (trimmed.length === 0) {
throw new Error('Вставьте разметку SVG');
throw new ToolError('errors.svgSize');
}
const blob = new Blob([trimmed], { type: 'image/svg+xml' });
const url = URL.createObjectURL(blob);
@@ -135,7 +139,7 @@ export async function decodeSvgText(text: string, targetWidth?: number): Promise
const img = new Image();
await new Promise<void>((resolve, reject) => {
img.onload = () => resolve();
img.onerror = () => reject(new Error('Не удалось загрузить SVG — проверьте разметку'));
img.onerror = () => reject(new ToolError('errors.svgLoad'));
img.src = url;
});
const w = targetWidth ?? img.naturalWidth ?? 300;
@@ -144,8 +148,8 @@ export async function decodeSvgText(text: string, targetWidth?: number): Promise
const canvas = document.createElement('canvas');
canvas.width = w;
canvas.height = h;
const ctx = canvas.getContext('2d', { willReadFrequently: true });
if (!ctx) throw new Error('Canvas 2D context недоступен в этом браузере');
const ctx = canvas.getContext('2d', { willReadFrequently: true });
if (!ctx) throw new ToolError('errors.noCanvasCtx');
ctx.drawImage(img, 0, 0, w, h);
const imageData = ctx.getImageData(0, 0, w, h);
return { width: imageData.width, height: imageData.height, data: imageData.data };
+8 -6
View File
@@ -1,4 +1,5 @@
import type { PixelImage } from './types';
import { ToolError } from './errors';
export function pixelsToHex(img: PixelImage): string {
const rows: string[] = [];
@@ -15,20 +16,21 @@ export function pixelsToHex(img: PixelImage): string {
export function hexToPixels(text: string, width: number): PixelImage {
if (!Number.isInteger(width) || width < 1) {
throw new Error('Укажите ширину изображения (целое число >= 1)');
throw new ToolError('errors.widthInt');
}
const tokens = text.trim().split(/\s+/).filter((t) => t.length > 0);
if (tokens.length === 0) {
throw new Error('Вставьте hex-данные пикселей');
throw new ToolError('errors.noHexPixels');
}
if (tokens.some((t) => !/^[0-9a-fA-F]{8}$/.test(t))) {
throw new Error('Каждый пиксель должен быть 8 hex-символов RRGGBBAA, разделённых пробелами');
throw new ToolError('errors.badPixelToken');
}
const height = tokens.length / width;
if (!Number.isInteger(height)) {
throw new Error(
`Число пикселей (${tokens.length}) не делится на ширину ${width} без остатка`
);
throw new ToolError('errors.pixelCountMismatch', {
count: tokens.length,
width
});
}
const data = new Uint8ClampedArray(tokens.length * 4);
tokens.forEach((token, index) => {