feat: add registry
This commit is contained in:
@@ -1,4 +1,4 @@
|
|||||||
export type CategoryId = 'convert' | 'alpha' | 'color' | 'geometry' | 'analyze';
|
export type CategoryId = 'convert' | 'alpha' | 'color' | 'geometry' | 'analyze' | 'generate';
|
||||||
|
|
||||||
export type Category = { id: CategoryId; label: string };
|
export type Category = { id: CategoryId; label: string };
|
||||||
|
|
||||||
@@ -7,5 +7,6 @@ export const CATEGORIES: Category[] = [
|
|||||||
{ id: 'alpha', label: 'Прозрачность' },
|
{ id: 'alpha', label: 'Прозрачность' },
|
||||||
{ id: 'color', label: 'Цвет' },
|
{ id: 'color', label: 'Цвет' },
|
||||||
{ id: 'geometry', label: 'Геометрия' },
|
{ id: 'geometry', label: 'Геометрия' },
|
||||||
{ id: 'analyze', label: 'Анализ' }
|
{ id: 'analyze', label: 'Анализ' },
|
||||||
|
{ id: 'generate', label: 'Генерация' }
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import { imageInfo } from './analyze';
|
import {
|
||||||
|
hasTransparency,
|
||||||
|
imageInfo,
|
||||||
|
isGrayscale,
|
||||||
|
orientationOf
|
||||||
|
} from './analyze';
|
||||||
import { makeImage } from './test-helpers';
|
import { makeImage } from './test-helpers';
|
||||||
|
|
||||||
describe('imageInfo', () => {
|
describe('imageInfo', () => {
|
||||||
@@ -43,3 +48,29 @@ describe('imageInfo', () => {
|
|||||||
expect(info.height).toBe(3);
|
expect(info.height).toBe(3);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('isGrayscale', () => {
|
||||||
|
it('серые пиксели — монохром', () => {
|
||||||
|
expect(isGrayscale(makeImage(1, 1, [[10, 10, 10, 255]]))).toBe(true);
|
||||||
|
});
|
||||||
|
it('цветной пиксель ломает монохром', () => {
|
||||||
|
expect(isGrayscale(makeImage(1, 1, [[10, 11, 10, 255]]))).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('hasTransparency', () => {
|
||||||
|
it('альфа ниже 255 — прозрачность есть', () => {
|
||||||
|
expect(hasTransparency(makeImage(1, 1, [[0, 0, 0, 254]]))).toBe(true);
|
||||||
|
});
|
||||||
|
it('все пиксели непрозрачны', () => {
|
||||||
|
expect(hasTransparency(makeImage(1, 1, [[0, 0, 0, 255]]))).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('orientationOf', () => {
|
||||||
|
it('определяет ориентацию', () => {
|
||||||
|
expect(orientationOf(makeImage(2, 3, new Array(6).fill([1, 1, 1, 1])))).toBe('portrait');
|
||||||
|
expect(orientationOf(makeImage(3, 2, new Array(6).fill([1, 1, 1, 1])))).toBe('landscape');
|
||||||
|
expect(orientationOf(makeImage(2, 2, new Array(4).fill([1, 1, 1, 1])))).toBe('square');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -24,3 +24,27 @@ export function imageInfo(img: PixelImage): ImageInfo {
|
|||||||
}
|
}
|
||||||
return { width: img.width, height: img.height, hasAlpha, colorCount: seen.size };
|
return { width: img.width, height: img.height, hasAlpha, colorCount: seen.size };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function isGrayscale(img: PixelImage): boolean {
|
||||||
|
for (let i = 0; i < img.data.length; i += 4) {
|
||||||
|
if (img.data[i] !== img.data[i + 1] || img.data[i + 1] !== img.data[i + 2]) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasTransparency(img: PixelImage): boolean {
|
||||||
|
for (let i = 3; i < img.data.length; i += 4) {
|
||||||
|
if (img.data[i] < 255) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Orientation = 'portrait' | 'landscape' | 'square';
|
||||||
|
|
||||||
|
export function orientationOf(img: PixelImage): Orientation {
|
||||||
|
if (img.height > img.width) return 'portrait';
|
||||||
|
if (img.width > img.height) return 'landscape';
|
||||||
|
return 'square';
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { gradientImage, noiseImage, solidImage } from './generate';
|
||||||
|
|
||||||
|
describe('solidImage', () => {
|
||||||
|
it('заливает весь холст заданным цветом', () => {
|
||||||
|
const out = solidImage(2, 2, [10, 20, 30, 255]);
|
||||||
|
expect(out.width).toBe(2);
|
||||||
|
expect([...out.data]).toEqual(new Array(4).fill([10, 20, 30, 255]).flat());
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
[0, 10],
|
||||||
|
[10, 0],
|
||||||
|
[2.5, 10],
|
||||||
|
[-1, 5]
|
||||||
|
])('бросает ошибку на размерах %i x %i', (w, h) => {
|
||||||
|
expect(() => solidImage(w, h, [0, 0, 0, 255])).toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('noiseImage', () => {
|
||||||
|
it('детерминирован: одно зерно — одни байты', () => {
|
||||||
|
expect([...noiseImage(4, 4, 42).data]).toEqual([...noiseImage(4, 4, 42).data]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('разные зерна дают разные данные', () => {
|
||||||
|
const a = [...noiseImage(8, 8, 1).data];
|
||||||
|
const b = [...noiseImage(8, 8, 2).data];
|
||||||
|
expect(a).not.toEqual(b);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('альфа всегда непрозрачная', () => {
|
||||||
|
const data = noiseImage(3, 3, 7).data;
|
||||||
|
for (let i = 3; i < data.length; i += 4) {
|
||||||
|
expect(data[i]).toBe(255);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('gradientImage', () => {
|
||||||
|
it('горизонтальный градиент идёт от цвета A к цвету B', () => {
|
||||||
|
const out = gradientImage(
|
||||||
|
3,
|
||||||
|
1,
|
||||||
|
[0, 0, 0, 255],
|
||||||
|
[255, 255, 255, 255],
|
||||||
|
'horizontal'
|
||||||
|
);
|
||||||
|
const px = (x: number) => [...out.data.slice(x * 4, x * 4 + 4)];
|
||||||
|
expect(px(0)).toEqual([0, 0, 0, 255]);
|
||||||
|
expect(px(1)).toEqual([128, 128, 128, 255]);
|
||||||
|
expect(px(2)).toEqual([255, 255, 255, 255]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('вертикальный градиент меняется по строкам', () => {
|
||||||
|
const out = gradientImage(
|
||||||
|
1,
|
||||||
|
2,
|
||||||
|
[0, 0, 0, 255],
|
||||||
|
[100, 100, 100, 255],
|
||||||
|
'vertical'
|
||||||
|
);
|
||||||
|
expect(out.data[0]).toBe(0);
|
||||||
|
expect(out.data[4]).toBe(100);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import type { PixelImage } from './types';
|
||||||
|
|
||||||
|
export function solidImage(
|
||||||
|
width: number,
|
||||||
|
height: number,
|
||||||
|
rgba: [number, number, number, number]
|
||||||
|
): PixelImage {
|
||||||
|
if (!Number.isInteger(width) || !Number.isInteger(height) || width < 1 || height < 1) {
|
||||||
|
throw new Error('Размеры должны быть целыми числами >= 1');
|
||||||
|
}
|
||||||
|
const data = new Uint8ClampedArray(width * height * 4);
|
||||||
|
for (let i = 0; i < data.length; i += 4) {
|
||||||
|
data[i] = rgba[0];
|
||||||
|
data[i + 1] = rgba[1];
|
||||||
|
data[i + 2] = rgba[2];
|
||||||
|
data[i + 3] = rgba[3];
|
||||||
|
}
|
||||||
|
return { width, height, data };
|
||||||
|
}
|
||||||
|
|
||||||
|
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');
|
||||||
|
}
|
||||||
|
const random = mulberry32(seed);
|
||||||
|
const data = new Uint8ClampedArray(width * height * 4);
|
||||||
|
for (let i = 0; i < data.length; i += 4) {
|
||||||
|
data[i] = Math.floor(random() * 256);
|
||||||
|
data[i + 1] = Math.floor(random() * 256);
|
||||||
|
data[i + 2] = Math.floor(random() * 256);
|
||||||
|
data[i + 3] = 255;
|
||||||
|
}
|
||||||
|
return { width, height, data };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function gradientImage(
|
||||||
|
width: number,
|
||||||
|
height: number,
|
||||||
|
fromRgba: [number, number, number, number],
|
||||||
|
toRgba: [number, number, number, number],
|
||||||
|
direction: 'horizontal' | 'vertical'
|
||||||
|
): PixelImage {
|
||||||
|
if (!Number.isInteger(width) || !Number.isInteger(height) || width < 1 || height < 1) {
|
||||||
|
throw new Error('Размеры должны быть целыми числами >= 1');
|
||||||
|
}
|
||||||
|
const out: PixelImage = {
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
data: new Uint8ClampedArray(width * height * 4)
|
||||||
|
};
|
||||||
|
const steps = (direction === 'horizontal' ? width : height) - 1;
|
||||||
|
for (let y = 0; y < height; y++) {
|
||||||
|
for (let x = 0; x < width; x++) {
|
||||||
|
const t = steps === 0 ? 0 : (direction === 'horizontal' ? x : y) / steps;
|
||||||
|
const i = (y * width + x) * 4;
|
||||||
|
out.data[i] = fromRgba[0] + (toRgba[0] - fromRgba[0]) * t;
|
||||||
|
out.data[i + 1] = fromRgba[1] + (toRgba[1] - fromRgba[1]) * t;
|
||||||
|
out.data[i + 2] = fromRgba[2] + (toRgba[2] - fromRgba[2]) * t;
|
||||||
|
out.data[i + 3] = fromRgba[3] + (toRgba[3] - fromRgba[3]) * t;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mulberry32(seed: number): () => number {
|
||||||
|
let a = seed >>> 0;
|
||||||
|
return () => {
|
||||||
|
a |= 0;
|
||||||
|
a = (a + 0x6d2b79f5) | 0;
|
||||||
|
let t = Math.imul(a ^ (a >>> 15), 1 | a);
|
||||||
|
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
||||||
|
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -51,11 +51,18 @@ const PLAN_TOOL_IDS = [
|
|||||||
'remove-alpha-channel-png',
|
'remove-alpha-channel-png',
|
||||||
'set-alpha-channel-png',
|
'set-alpha-channel-png',
|
||||||
'extract-alpha-mask-png',
|
'extract-alpha-mask-png',
|
||||||
'round-corners-png'
|
'round-corners-png',
|
||||||
|
'create-empty-png',
|
||||||
|
'single-color-png',
|
||||||
|
'random-noise-png',
|
||||||
|
'linear-gradient-png',
|
||||||
|
'png-is-grayscale',
|
||||||
|
'png-is-transparent',
|
||||||
|
'png-orientation'
|
||||||
];
|
];
|
||||||
|
|
||||||
describe('реестр инструментов', () => {
|
describe('реестр инструментов', () => {
|
||||||
it('содержит ровно 41 инструмент из плана', () => {
|
it('содержит ровно 48 инструментов из плана', () => {
|
||||||
expect(TOOLS.map((t) => t.id).sort()).toEqual([...PLAN_TOOL_IDS].sort());
|
expect(TOOLS.map((t) => t.id).sort()).toEqual([...PLAN_TOOL_IDS].sort());
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -74,6 +81,8 @@ describe('реестр инструментов', () => {
|
|||||||
it.each(TOOLS.map((t) => [t.id, t] as const))('%s: исполнители определены', (_, tool) => {
|
it.each(TOOLS.map((t) => [t.id, t] as const))('%s: исполнители определены', (_, tool) => {
|
||||||
if (tool.resultType === 'text') {
|
if (tool.resultType === 'text') {
|
||||||
expect(typeof tool.toText).toBe('function');
|
expect(typeof tool.toText).toBe('function');
|
||||||
|
} else if (tool.sourceMode === 'none') {
|
||||||
|
expect(typeof tool.generate).toBe('function');
|
||||||
} else {
|
} else {
|
||||||
expect(typeof tool.run).toBe('function');
|
expect(typeof tool.run).toBe('function');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,12 @@ import {
|
|||||||
roundCorners,
|
roundCorners,
|
||||||
setAlphaChannel
|
setAlphaChannel
|
||||||
} from './core/alpha';
|
} from './core/alpha';
|
||||||
|
import {
|
||||||
|
hasTransparency,
|
||||||
|
isGrayscale,
|
||||||
|
orientationOf
|
||||||
|
} from './core/analyze';
|
||||||
|
import { gradientImage, noiseImage, solidImage } from './core/generate';
|
||||||
import {
|
import {
|
||||||
brightnessContrast,
|
brightnessContrast,
|
||||||
changeHue,
|
changeHue,
|
||||||
@@ -113,6 +119,20 @@ 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}"`);
|
||||||
|
}
|
||||||
|
const d = match[1];
|
||||||
|
return [
|
||||||
|
parseInt(d.slice(0, 2), 16),
|
||||||
|
parseInt(d.slice(2, 4), 16),
|
||||||
|
parseInt(d.slice(4, 6), 16),
|
||||||
|
alpha
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
export const TOOLS: ToolEntry[] = [
|
export const TOOLS: ToolEntry[] = [
|
||||||
decodeToPng(
|
decodeToPng(
|
||||||
'jpg-to-png',
|
'jpg-to-png',
|
||||||
@@ -599,6 +619,132 @@ export const TOOLS: ToolEntry[] = [
|
|||||||
params: [],
|
params: [],
|
||||||
resultType: 'info',
|
resultType: 'info',
|
||||||
run: (img) => clonePixelImage(img)
|
run: (img) => clonePixelImage(img)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'create-empty-png',
|
||||||
|
title: 'Создать пустой PNG',
|
||||||
|
description: 'Генерирует холст выбранного размера — прозрачный или залитый цветом.',
|
||||||
|
category: 'generate',
|
||||||
|
sourceMode: 'none',
|
||||||
|
params: [
|
||||||
|
{ id: 'width', label: 'Ширина', type: 'number', min: 1, max: 20000, step: 1, default: 800 },
|
||||||
|
{ id: 'height', label: 'Высота', type: 'number', min: 1, max: 20000, step: 1, default: 600 },
|
||||||
|
{ id: 'transparent', label: 'Прозрачный', type: 'checkbox', default: true },
|
||||||
|
{ id: 'color', label: 'Цвет', type: 'color', default: '#ffffff' }
|
||||||
|
],
|
||||||
|
generate: (p) =>
|
||||||
|
solidImage(
|
||||||
|
Math.trunc(num(p, 'width')),
|
||||||
|
Math.trunc(num(p, 'height')),
|
||||||
|
p['transparent'] === true
|
||||||
|
? [0, 0, 0, 0]
|
||||||
|
: hexToRgba(str(p, 'color'))
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'single-color-png',
|
||||||
|
title: 'Создать одноцветный PNG',
|
||||||
|
description: 'Генерирует прямоугольник заданного размера и цвета.',
|
||||||
|
category: 'generate',
|
||||||
|
sourceMode: 'none',
|
||||||
|
params: [
|
||||||
|
{ id: 'width', label: 'Ширина', type: 'number', min: 1, max: 20000, step: 1, default: 256 },
|
||||||
|
{ id: 'height', label: 'Высота', type: 'number', min: 1, max: 20000, step: 1, default: 256 },
|
||||||
|
{ id: 'color', label: 'Цвет', type: 'color', default: '#ff0000' }
|
||||||
|
],
|
||||||
|
generate: (p) =>
|
||||||
|
solidImage(
|
||||||
|
Math.trunc(num(p, 'width')),
|
||||||
|
Math.trunc(num(p, 'height')),
|
||||||
|
hexToRgba(str(p, 'color'))
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'random-noise-png',
|
||||||
|
title: 'Создать случайный шум PNG',
|
||||||
|
description:
|
||||||
|
'Генерирует картинку со случайными пикселями. Зерно фиксирует результат: одно зерно — одна картинка.',
|
||||||
|
category: 'generate',
|
||||||
|
sourceMode: 'none',
|
||||||
|
params: [
|
||||||
|
{ id: 'width', label: 'Ширина', type: 'number', min: 1, max: 5000, step: 1, default: 512 },
|
||||||
|
{ id: 'height', label: 'Высота', type: 'number', min: 1, max: 5000, step: 1, default: 512 },
|
||||||
|
{ id: 'seed', label: 'Зерно', type: 'number', min: 0, max: 999999999, step: 1, default: 1 }
|
||||||
|
],
|
||||||
|
generate: (p) => noiseImage(Math.trunc(num(p, 'width')), Math.trunc(num(p, 'height')), num(p, 'seed'))
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'linear-gradient-png',
|
||||||
|
title: 'Создать градиент PNG',
|
||||||
|
description: 'Генерирует плавный переход между двумя цветами по горизонтали или вертикали.',
|
||||||
|
category: 'generate',
|
||||||
|
sourceMode: 'none',
|
||||||
|
params: [
|
||||||
|
{ id: 'width', label: 'Ширина', type: 'number', min: 1, max: 20000, step: 1, default: 800 },
|
||||||
|
{ id: 'height', label: 'Высота', type: 'number', min: 1, max: 20000, step: 1, default: 600 },
|
||||||
|
{ id: 'fromColor', label: 'Цвет начала', type: 'color', default: '#000000' },
|
||||||
|
{ id: 'toColor', label: 'Цвет конца', type: 'color', default: '#ffffff' },
|
||||||
|
{
|
||||||
|
id: 'direction',
|
||||||
|
label: 'Направление',
|
||||||
|
type: 'select',
|
||||||
|
default: 'horizontal',
|
||||||
|
options: [
|
||||||
|
{ value: 'horizontal', label: 'По горизонтали' },
|
||||||
|
{ value: 'vertical', label: 'По вертикали' }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
generate: (p) =>
|
||||||
|
gradientImage(
|
||||||
|
Math.trunc(num(p, 'width')),
|
||||||
|
Math.trunc(num(p, 'height')),
|
||||||
|
hexToRgba(str(p, 'fromColor')),
|
||||||
|
hexToRgba(str(p, 'toColor')),
|
||||||
|
str(p, 'direction') === 'vertical' ? 'vertical' : 'horizontal'
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'png-is-grayscale',
|
||||||
|
title: 'Проверить: PNG монохромный?',
|
||||||
|
description: 'Сообщает, состоит ли изображение только из оттенков серого.',
|
||||||
|
category: 'analyze',
|
||||||
|
params: [],
|
||||||
|
resultType: 'text',
|
||||||
|
toText: (img) =>
|
||||||
|
isGrayscale(img)
|
||||||
|
? 'Да — все пиксели являются оттенками серого.'
|
||||||
|
: 'Нет — найдены цветные пиксели.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'png-is-transparent',
|
||||||
|
title: 'Проверить: PNG прозрачный?',
|
||||||
|
description: 'Сообщает, есть ли в изображении прозрачные или полупрозрачные пиксели.',
|
||||||
|
category: 'analyze',
|
||||||
|
params: [],
|
||||||
|
resultType: 'text',
|
||||||
|
toText: (img) =>
|
||||||
|
hasTransparency(img)
|
||||||
|
? 'Да — есть прозрачные или полупрозрачные пиксели.'
|
||||||
|
: 'Нет — все пиксели полностью непрозрачны.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'png-orientation',
|
||||||
|
title: 'Ориентация PNG',
|
||||||
|
description: 'Сообщает, портрет это, ландшафт или квадрат.',
|
||||||
|
category: 'analyze',
|
||||||
|
params: [],
|
||||||
|
resultType: 'text',
|
||||||
|
toText: (img) => {
|
||||||
|
switch (orientationOf(img)) {
|
||||||
|
case 'portrait':
|
||||||
|
return 'Портрет — высота больше ширины.';
|
||||||
|
case 'landscape':
|
||||||
|
return 'Ландшафт — ширина больше высоты.';
|
||||||
|
default:
|
||||||
|
return 'Квадрат — стороны равны.';
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user