From 6fae98b25c9db23f27effdd6c73914cad5a9a647 Mon Sep 17 00:00:00 2001 From: Ku6epXBOCTuK Date: Sun, 23 Aug 2026 12:41:17 +0500 Subject: [PATCH] feat: add registry --- web/src/lib/categories.ts | 5 +- web/src/lib/core/analyze.test.ts | 33 ++++++- web/src/lib/core/analyze.ts | 24 +++++ web/src/lib/core/generate.test.ts | 66 ++++++++++++++ web/src/lib/core/generate.ts | 74 +++++++++++++++ web/src/lib/registry.test.ts | 13 ++- web/src/lib/registry.ts | 146 ++++++++++++++++++++++++++++++ 7 files changed, 356 insertions(+), 5 deletions(-) create mode 100644 web/src/lib/core/generate.test.ts create mode 100644 web/src/lib/core/generate.ts diff --git a/web/src/lib/categories.ts b/web/src/lib/categories.ts index a59f2d3..71f20d1 100644 --- a/web/src/lib/categories.ts +++ b/web/src/lib/categories.ts @@ -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 }; @@ -7,5 +7,6 @@ export const CATEGORIES: Category[] = [ { id: 'alpha', label: 'Прозрачность' }, { id: 'color', label: 'Цвет' }, { id: 'geometry', label: 'Геометрия' }, - { id: 'analyze', label: 'Анализ' } + { id: 'analyze', label: 'Анализ' }, + { id: 'generate', label: 'Генерация' } ]; diff --git a/web/src/lib/core/analyze.test.ts b/web/src/lib/core/analyze.test.ts index 8437780..05ebb27 100644 --- a/web/src/lib/core/analyze.test.ts +++ b/web/src/lib/core/analyze.test.ts @@ -1,5 +1,10 @@ import { describe, expect, it } from 'vitest'; -import { imageInfo } from './analyze'; +import { + hasTransparency, + imageInfo, + isGrayscale, + orientationOf +} from './analyze'; import { makeImage } from './test-helpers'; describe('imageInfo', () => { @@ -43,3 +48,29 @@ describe('imageInfo', () => { 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'); + }); +}); diff --git a/web/src/lib/core/analyze.ts b/web/src/lib/core/analyze.ts index 4508e0b..6987d25 100644 --- a/web/src/lib/core/analyze.ts +++ b/web/src/lib/core/analyze.ts @@ -24,3 +24,27 @@ export function imageInfo(img: PixelImage): ImageInfo { } 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'; +} diff --git a/web/src/lib/core/generate.test.ts b/web/src/lib/core/generate.test.ts new file mode 100644 index 0000000..e243f8a --- /dev/null +++ b/web/src/lib/core/generate.test.ts @@ -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); + }); +}); diff --git a/web/src/lib/core/generate.ts b/web/src/lib/core/generate.ts new file mode 100644 index 0000000..a5908bb --- /dev/null +++ b/web/src/lib/core/generate.ts @@ -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; + }; +} diff --git a/web/src/lib/registry.test.ts b/web/src/lib/registry.test.ts index e34aa55..228a7da 100644 --- a/web/src/lib/registry.test.ts +++ b/web/src/lib/registry.test.ts @@ -51,11 +51,18 @@ const PLAN_TOOL_IDS = [ 'remove-alpha-channel-png', 'set-alpha-channel-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('реестр инструментов', () => { - it('содержит ровно 41 инструмент из плана', () => { + it('содержит ровно 48 инструментов из плана', () => { 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) => { if (tool.resultType === 'text') { expect(typeof tool.toText).toBe('function'); + } else if (tool.sourceMode === 'none') { + expect(typeof tool.generate).toBe('function'); } else { expect(typeof tool.run).toBe('function'); } diff --git a/web/src/lib/registry.ts b/web/src/lib/registry.ts index f900b89..f5830ac 100644 --- a/web/src/lib/registry.ts +++ b/web/src/lib/registry.ts @@ -8,6 +8,12 @@ import { roundCorners, setAlphaChannel } from './core/alpha'; +import { + hasTransparency, + isGrayscale, + orientationOf +} from './core/analyze'; +import { gradientImage, noiseImage, solidImage } from './core/generate'; import { brightnessContrast, 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[] = [ decodeToPng( 'jpg-to-png', @@ -599,6 +619,132 @@ export const TOOLS: ToolEntry[] = [ params: [], resultType: 'info', 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 'Квадрат — стороны равны.'; + } + } } ];