diff --git a/web/src/lib/core/alpha.test.ts b/web/src/lib/core/alpha.test.ts index 8fc3473..e304a2d 100644 --- a/web/src/lib/core/alpha.test.ts +++ b/web/src/lib/core/alpha.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { parseHex, removeColorToAlpha } from './alpha'; +import { flattenOntoColor, parseHex, removeColorToAlpha } from './alpha'; import { makeImage } from './test-helpers'; describe('removeColorToAlpha', () => { @@ -55,6 +55,23 @@ describe('removeColorToAlpha', () => { }); }); +describe('flattenOntoColor', () => { + it('непрозрачный пиксель не меняется, альфа становится 255', () => { + const out = flattenOntoColor(makeImage(1, 1, [[10, 20, 30, 255]]), '#ffffff'); + expect([...out.data]).toEqual([10, 20, 30, 255]); + }); + + it('полностью прозрачный пиксель становится цветом подложки', () => { + const out = flattenOntoColor(makeImage(1, 1, [[99, 99, 99, 0]]), '#ff8040'); + expect([...out.data]).toEqual([255, 128, 64, 255]); + }); + + it('полупрозрачный пиксель смешивается с подложкой', () => { + const out = flattenOntoColor(makeImage(1, 1, [[10, 20, 30, 128]]), '#ffffff'); + expect([...out.data]).toEqual([132, 137, 142, 255]); + }); +}); + describe('parseHex', () => { it('разбирает #rrggbb, rrggbb, #rgb', () => { expect(parseHex('#ff8040')).toEqual([255, 128, 64]); diff --git a/web/src/lib/core/alpha.ts b/web/src/lib/core/alpha.ts index 0635a57..10eb9e3 100644 --- a/web/src/lib/core/alpha.ts +++ b/web/src/lib/core/alpha.ts @@ -1,4 +1,4 @@ -import type { PixelImage } from './types'; +import { createPixelImage, type PixelImage } from './types'; const MAX_COLOR_DISTANCE = Math.sqrt(3 * 255 * 255); @@ -22,6 +22,20 @@ export function removeColorToAlpha( return out; } +export function flattenOntoColor(img: PixelImage, hex: string): PixelImage { + const [bgR, bgG, bgB] = parseHex(hex); + const out = createPixelImage(img.width, img.height); + for (let i = 0; i < out.data.length; i += 4) { + const a = img.data[i + 3] / 255; + const inv = 1 - a; + out.data[i] = img.data[i] * a + bgR * inv; + out.data[i + 1] = img.data[i + 1] * a + bgG * inv; + out.data[i + 2] = img.data[i + 2] * a + bgB * inv; + out.data[i + 3] = 255; + } + return out; +} + export function parseHex(hex: string): [number, number, number] { const match = /^#?([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(hex.trim()); if (!match) { diff --git a/web/src/lib/registry.test.ts b/web/src/lib/registry.test.ts new file mode 100644 index 0000000..a238256 --- /dev/null +++ b/web/src/lib/registry.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, it } from 'vitest'; +import { CATEGORIES } from './categories'; +import { defaultParams, getTool, outputOf, TOOLS, type ParamDef, type ToolEntry } from './registry'; + +const PLAN_TOOL_IDS = [ + 'resize-png', + 'crop-png', + 'rotate-png', + 'flip-png', + 'grayscale-png', + 'invert-colors-png', + 'adjust-brightness-contrast-png', + 'convert-png-to-jpg', + 'convert-png-to-webp', + 'remove-color-from-png', + 'png-info' +]; + +describe('реестр инструментов', () => { + it('содержит ровно 11 инструментов из плана MVP', () => { + expect(TOOLS.map((t) => t.id).sort()).toEqual([...PLAN_TOOL_IDS].sort()); + }); + + it('id уникальны и в kebab-case', () => { + const ids = TOOLS.map((t) => t.id); + expect(new Set(ids).size).toBe(ids.length); + for (const id of ids) { + expect(id).toMatch(/^[a-z0-9]+(-[a-z0-9]+)*$/); + } + }); + + it.each(TOOLS.map((t) => [t.id, t] as const))('%s: категория валидна', (_, tool) => { + expect(CATEGORIES.map((c) => c.id)).toContain(tool.category); + }); + + it.each(TOOLS.map((t) => [t.id, t] as const))('%s: run определён', (_, tool) => { + expect(typeof tool.run).toBe('function'); + expect(tool.title.length).toBeGreaterThan(0); + expect(tool.description.length).toBeGreaterThan(0); + }); + + it('у select дефолт входит в options, у number дефолт в диапазоне', () => { + for (const tool of TOOLS) { + for (const param of tool.params) { + if (param.type === 'select') { + expect(param.options.map((o) => o.value)).toContain(param.default); + expect(param.options.length).toBeGreaterThan(0); + } + if (param.type === 'number') { + expect(param.min === undefined || param.default >= param.min).toBe(true); + expect(param.max === undefined || param.default <= param.max).toBe(true); + } + if (param.type === 'color') { + expect(param.default).toMatch(/^#[0-9a-f]{6}$/i); + } + } + } + }); + + it('id параметров уникальны внутри инструмента', () => { + for (const tool of TOOLS) { + const ids = tool.params.map((p) => p.id); + expect(new Set(ids).size).toBe(ids.length); + } + }); + + it('qualityParamId ссылается на существующий числовой параметр', () => { + for (const tool of TOOLS) { + const output = outputOf(tool); + if (output?.qualityParamId) { + const param = tool.params.find( + (p): p is Extract => p.id === output.qualityParamId + ); + expect(param).toBeDefined(); + } + } + }); + + it('png-info не имеет формата вывода, конвертеры имеют jpeg/webp, остальные — png', () => { + expect(outputOf(getToolOrThrow('png-info'))).toBeUndefined(); + expect(outputOf(getToolOrThrow('convert-png-to-jpg'))?.mime).toBe('image/jpeg'); + expect(outputOf(getToolOrThrow('convert-png-to-webp'))?.mime).toBe('image/webp'); + expect(outputOf(getToolOrThrow('grayscale-png'))).toEqual({ mime: 'image/png', ext: 'png' }); + }); + + it('defaultParams собирает значения по умолчанию', () => { + const resize = getToolOrThrow('resize-png'); + expect(defaultParams(resize)).toEqual({ width: 0, height: 0, keepAspect: true }); + }); +}); + +function getToolOrThrow(id: string): ToolEntry { + const tool = getTool(id); + if (!tool) throw new Error(`Инструмент "${id}" не найден`); + return tool; +} diff --git a/web/src/lib/registry.ts b/web/src/lib/registry.ts new file mode 100644 index 0000000..939658f --- /dev/null +++ b/web/src/lib/registry.ts @@ -0,0 +1,239 @@ +import type { CategoryId } from './categories'; +import { flattenOntoColor, removeColorToAlpha } from './core/alpha'; +import { brightnessContrast, grayscale, invert } from './core/color'; +import { crop, flip, resize, rotate90 } from './core/geometry'; +import type { OutputMime } from './core/io'; +import { clonePixelImage, type PixelImage } from './core/types'; + +export type ParamDef = + | { + id: string; + label: string; + type: 'number'; + min?: number; + max?: number; + step?: number; + default: number; + } + | { + id: string; + label: string; + type: 'select'; + options: { value: string; label: string }[]; + default: string; + } + | { id: string; label: string; type: 'checkbox'; default: boolean } + | { id: string; label: string; type: 'color'; default: string }; + +export type OutputFormat = { + mime: OutputMime; + ext: string; + qualityParamId?: string; +}; + +export type ToolEntry = { + id: string; + title: string; + description: string; + category: CategoryId; + params: ParamDef[]; + run: (img: PixelImage, params: Record) => Promise | PixelImage; + resultType?: 'image' | 'info'; + output?: OutputFormat; +}; + +const PNG_OUTPUT: OutputFormat = { mime: 'image/png', ext: 'png' }; + +function num(params: Record, id: string): number { + const v = params[id]; + if (typeof v !== 'number' || !Number.isFinite(v)) { + throw new Error(`Параметр "${id}" должен быть числом`); + } + return v; +} + +function str(params: Record, id: string): string { + const v = params[id]; + if (typeof v !== 'string') { + throw new Error(`Параметр "${id}" должен быть строкой`); + } + return v; +} + +export const TOOLS: ToolEntry[] = [ + { + id: 'resize-png', + title: 'Изменить размер PNG', + description: + 'Масштабирование изображения с билинейной интерполяцией. При сохранении пропорций укажите только ширину или только высоту — вторая сторона рассчитается автоматически.', + category: 'geometry', + params: [ + { id: 'width', label: 'Ширина (0 — авто)', type: 'number', min: 0, max: 20000, step: 1, default: 0 }, + { id: 'height', label: 'Высота (0 — авто)', type: 'number', min: 0, max: 20000, step: 1, default: 0 }, + { id: 'keepAspect', label: 'Сохранять пропорции', type: 'checkbox', default: true } + ], + run: (img, p) => { + const keepAspect = p['keepAspect'] === true; + let w = Math.trunc(num(p, 'width')); + let h = Math.trunc(num(p, 'height')); + if (keepAspect) { + if (w > 0 && h > 0) { + throw new Error('При сохранении пропорций укажите только ширину или только высоту'); + } + if (w > 0) { + h = Math.max(1, Math.round((img.height / img.width) * w)); + } else if (h > 0) { + w = Math.max(1, Math.round((img.width / img.height) * h)); + } + } + if (w <= 0 || h <= 0) { + throw new Error('Укажите ширину и/или высоту нового размера'); + } + return resize(img, w, h); + } + }, + { + id: 'crop-png', + title: 'Обрезать PNG', + description: + 'Вырезает прямоугольную область. Координаты и размеры выходят за границы изображения — область усекается до пересечения с картинкой.', + category: 'geometry', + params: [ + { id: 'x', label: 'X (слева)', type: 'number', min: -100000, max: 100000, step: 1, default: 0 }, + { id: 'y', label: 'Y (сверху)', type: 'number', min: -100000, max: 100000, step: 1, default: 0 }, + { id: 'width', label: 'Ширина области', type: 'number', min: -100000, max: 100000, step: 1, default: 0 }, + { id: 'height', label: 'Высота области', type: 'number', min: -100000, max: 100000, step: 1, default: 0 } + ], + run: (img, p) => { + const w = Math.trunc(num(p, 'width')); + const h = Math.trunc(num(p, 'height')); + if (w <= 0 || h <= 0) { + throw new Error('Укажите ширину и высоту области обрезки'); + } + return crop(img, Math.trunc(num(p, 'x')), Math.trunc(num(p, 'y')), w, h); + } + }, + { + id: 'rotate-png', + title: 'Повернуть PNG', + description: 'Поворот на 90°, 180° или 270° по часовой стрелке без потери качества.', + category: 'geometry', + params: [ + { + id: 'angle', + label: 'Угол поворота', + type: 'select', + default: '90', + options: [ + { value: '90', label: '90° по часовой' }, + { value: '180', label: '180°' }, + { value: '270', label: '270° по часовой' } + ] + } + ], + run: (img, p) => rotate90(img, Number(str(p, 'angle')) / 90) + }, + { + id: 'flip-png', + title: 'Отразить PNG', + description: 'Зеркальное отражение по горизонтали или вертикали без потери качества.', + category: 'geometry', + params: [ + { + id: 'axis', + label: 'Ось отражения', + type: 'select', + default: 'horizontal', + options: [ + { value: 'horizontal', label: 'По горизонтали (слева направо)' }, + { value: 'vertical', label: 'По вертикали (сверху вниз)' } + ] + } + ], + run: (img, p) => flip(img, str(p, 'axis') === 'vertical' ? 'vertical' : 'horizontal') + }, + { + id: 'grayscale-png', + title: 'Чёрно-белый PNG', + description: 'Переводит изображение в оттенки серого по яркостной формуле BT.601. Альфа сохраняется.', + category: 'color', + params: [], + run: (img) => grayscale(img) + }, + { + id: 'invert-colors-png', + title: 'Инвертировать цвета PNG', + description: 'Обращает каждый цветовой канал (255 − значение). Альфа не меняется.', + category: 'color', + params: [], + run: (img) => invert(img) + }, + { + id: 'adjust-brightness-contrast-png', + title: 'Яркость и контраст PNG', + description: 'Изменяет яркость и контраст в диапазоне от −100 до +100. Значение 0 — без изменений.', + category: 'color', + params: [ + { id: 'brightness', label: 'Яркость', type: 'number', min: -100, max: 100, step: 1, default: 0 }, + { id: 'contrast', label: 'Контраст', type: 'number', min: -100, max: 100, step: 1, default: 0 } + ], + run: (img, p) => brightnessContrast(img, num(p, 'brightness'), num(p, 'contrast')) + }, + { + id: 'convert-png-to-jpg', + title: 'Конвертировать PNG в JPG', + description: + 'Прозрачность накладывается на выбранный цвет подложки (по умолчанию белый), результат сохраняется в JPEG.', + category: 'convert', + params: [ + { id: 'background', label: 'Цвет подложки', type: 'color', default: '#ffffff' }, + { id: 'quality', label: 'Качество JPEG', type: 'number', min: 1, max: 100, step: 1, default: 90 } + ], + output: { mime: 'image/jpeg', ext: 'jpg', qualityParamId: 'quality' }, + run: (img, p) => flattenOntoColor(img, str(p, 'background')) + }, + { + id: 'convert-png-to-webp', + title: 'Конвертировать PNG в WebP', + description: 'Перекодирует изображение в WebP с настраиваемым качеством. Прозрачность сохраняется.', + category: 'convert', + params: [{ id: 'quality', label: 'Качество WebP', type: 'number', min: 1, max: 100, step: 1, default: 90 }], + output: { mime: 'image/webp', ext: 'webp', qualityParamId: 'quality' }, + run: (img) => clonePixelImage(img) + }, + { + id: 'remove-color-from-png', + title: 'Удалить цвет из PNG (прозрачность)', + description: + 'Делает прозрачными все пиксели, близкие к выбранному цвету. Порог задаёт допустимое отклонение в процентах от максимального цветового расстояния.', + category: 'alpha', + params: [ + { id: 'targetColor', label: 'Цвет для удаления', type: 'color', default: '#00ff00' }, + { id: 'tolerance', label: 'Порог похожести, %', type: 'number', min: 0, max: 100, step: 1, default: 10 } + ], + run: (img, p) => removeColorToAlpha(img, str(p, 'targetColor'), num(p, 'tolerance')) + }, + { + id: 'png-info', + title: 'Информация о PNG', + description: + 'Показывает размеры, наличие альфа-канала и количество уникальных цветов загруженного изображения.', + category: 'analyze', + params: [], + resultType: 'info', + run: (img) => clonePixelImage(img) + } +]; + +export function getTool(id: string): ToolEntry | undefined { + return TOOLS.find((tool) => tool.id === id); +} + +export function defaultParams(tool: ToolEntry): Record { + return Object.fromEntries(tool.params.map((p) => [p.id, p.default])); +} + +export function outputOf(tool: ToolEntry): OutputFormat | undefined { + if (tool.resultType === 'info') return undefined; + return tool.output ?? PNG_OUTPUT; +}