mirror of
https://github.com/Ku6epXBOCTuK/easy-png-tools.git
synced 2026-09-14 21:46:35 +00:00
refactor: add error messages i18n
This commit is contained in:
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
+16
-12
@@ -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 };
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user