diff --git a/web/src/lib/core/alpha.test.ts b/web/src/lib/core/alpha.test.ts index da14499..783a1f6 100644 --- a/web/src/lib/core/alpha.test.ts +++ b/web/src/lib/core/alpha.test.ts @@ -1,7 +1,56 @@ import { describe, expect, it } from 'vitest'; -import { colorMask, flattenOntoColor, invertAlpha, parseHex, removeColorToAlpha } from './alpha'; +import { + colorMask, + extractAlphaMask, + flattenOntoColor, + invertAlpha, + parseHex, + removeColorToAlpha, + roundCorners, + setAlphaChannel +} from './alpha'; import { makeImage } from './test-helpers'; +describe('setAlphaChannel', () => { + it('задаёт константную альфу', () => { + const out = setAlphaChannel(makeImage(2, 1, [ + [1, 2, 3, 255], + [4, 5, 6, 0] + ]), 50); + expect(out.data[3]).toBe(128); + expect(out.data[7]).toBe(128); + expect(out.data[0]).toBe(1); + }); +}); + +describe('extractAlphaMask', () => { + it('переводит альфу в чёрно-белую непрозрачную маску', () => { + const out = extractAlphaMask(makeImage(2, 1, [ + [10, 20, 30, 255], + [40, 50, 60, 0] + ])); + expect([...out.data]).toEqual([ + 255, 255, 255, 255, + 0, 0, 0, 255 + ]); + }); +}); + +describe('roundCorners', () => { + it('срезает углы, центр и середины сторон остаются', () => { + const img = makeImage(11, 11, new Array(121).fill([100, 100, 100, 255])); + const out = roundCorners(img, 40); + expect(out.data[(0 * 11 + 0) * 4 + 3]).toBe(0); + expect(out.data[(5 * 11 + 5) * 4 + 3]).toBe(255); + expect(out.data[(5 * 11 + 0) * 4 + 3]).toBe(255); + }); + + it('нулевой радиус ничего не меняет', () => { + const img = makeImage(2, 2, new Array(4).fill([1, 2, 3, 255])); + expect([...roundCorners(img, 0).data]).toEqual([...img.data]); + }); +}); + describe('invertAlpha', () => { it('обращает альфу, RGB не трогает', () => { const out = invertAlpha(makeImage(1, 1, [[10, 20, 30, 128]])); diff --git a/web/src/lib/core/alpha.ts b/web/src/lib/core/alpha.ts index fc92b40..c554ed6 100644 --- a/web/src/lib/core/alpha.ts +++ b/web/src/lib/core/alpha.ts @@ -22,6 +22,46 @@ export function removeColorToAlpha( return out; } +export function setAlphaChannel(img: PixelImage, percent: number): PixelImage { + const alpha = Math.round((clamp(percent, 0, 100) / 100) * 255); + const out: PixelImage = { width: img.width, height: img.height, data: img.data.slice() }; + for (let i = 3; i < out.data.length; i += 4) { + out.data[i] = alpha; + } + return out; +} + +export function extractAlphaMask(img: PixelImage): PixelImage { + const out = createPixelImage(img.width, img.height); + for (let i = 0; i < out.data.length; i += 4) { + const v = img.data[i + 3]; + out.data[i] = v; + out.data[i + 1] = v; + out.data[i + 2] = v; + out.data[i + 3] = 255; + } + return out; +} + +export function roundCorners(img: PixelImage, radiusPercent: number): PixelImage { + const radius = (clamp(radiusPercent, 0, 50) / 100) * (Math.min(img.width, img.height) / 2); + if (radius < 1) return { width: img.width, height: img.height, data: img.data.slice() }; + const out: PixelImage = { width: img.width, height: img.height, data: img.data.slice() }; + const r2 = radius * radius; + for (let y = 0; y < out.height; y++) { + for (let x = 0; x < out.width; x++) { + const cx = clamp(x, radius, out.width - 1 - radius); + const cy = clamp(y, radius, out.height - 1 - radius); + const dx = x - cx; + const dy = y - cy; + if (dx * dx + dy * dy > r2) { + out.data[(y * out.width + x) * 4 + 3] = 0; + } + } + } + return out; +} + export function invertAlpha(img: PixelImage): PixelImage { const out = createPixelImage(img.width, img.height); for (let i = 0; i < out.data.length; i += 4) { diff --git a/web/src/lib/core/geometry.test.ts b/web/src/lib/core/geometry.test.ts index bd709a9..5fa0225 100644 --- a/web/src/lib/core/geometry.test.ts +++ b/web/src/lib/core/geometry.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { crop, flip, resize, rotate90 } from './geometry'; +import { centerByAlpha, crop, expandCanvas, flip, resize, rotate90, tile } from './geometry'; import { makeImage } from './test-helpers'; const square = () => @@ -126,6 +126,64 @@ describe('crop', () => { }); }); +describe('expandCanvas', () => { + const pixel = () => makeImage(1, 1, [[10, 20, 30, 255]]); + + it('прозрачное расширение кладёт пиксель со смещением', () => { + const out = expandCanvas(pixel(), 1, 2, 3, 4); + expect(out.width).toBe(5); + expect(out.height).toBe(7); + expect([...out.data.slice(0, 4)]).toEqual([0, 0, 0, 0]); + expect([...out.data.slice((2 * 5 + 1) * 4, (2 * 5 + 1) * 4 + 4)]).toEqual([10, 20, 30, 255]); + }); + + it('цветной фон заливает всё вокруг', () => { + const out = expandCanvas(pixel(), 1, 0, 0, 0, '#ffffff'); + expect(out.data[0]).toBe(255); + expect(out.data[3]).toBe(255); + expect(out.data[(0 * 2 + 1) * 4 + 3]).toBe(255); + }); +}); + +describe('tile', () => { + it('повторяет изображение по сетке', () => { + const out = tile(makeImage(1, 1, [[9, 9, 9, 255]]), 3, 2); + expect(out.width).toBe(3); + expect(out.height).toBe(2); + expect([...out.data].filter((_, i) => i % 4 === 0)).toEqual([9, 9, 9, 9, 9, 9]); + }); +}); + +describe('centerByAlpha', () => { + it('вырезает непрозрачный блок и центрирует на прежнем холсте', () => { + const img = makeImage(3, 3, [ + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [5, 5, 5, 255], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0] + ]); + const out = centerByAlpha(img); + expect(out.width).toBe(3); + expect(out.height).toBe(3); + const alphaAt = (x: number, y: number) => out.data[(y * 3 + x) * 4 + 3]; + expect(alphaAt(0, 0)).toBe(0); + expect(alphaAt(1, 1)).toBe(255); + }); + + it('полностью прозрачное изображение возвращается без изменений', () => { + const img = makeImage(2, 1, [ + [0, 0, 0, 0], + [0, 0, 0, 0] + ]); + expect([...centerByAlpha(img).data]).toEqual([...img.data]); + }); +}); + describe('resize', () => { const twoByTwo = () => makeImage(2, 2, [ diff --git a/web/src/lib/core/geometry.ts b/web/src/lib/core/geometry.ts index 2e7c88d..07b9d36 100644 --- a/web/src/lib/core/geometry.ts +++ b/web/src/lib/core/geometry.ts @@ -1,5 +1,85 @@ +import { parseHex } from './alpha'; import { clonePixelImage, createPixelImage, type PixelImage } from './types'; +export function expandCanvas( + img: PixelImage, + left: number, + top: number, + right: number, + bottom: number, + backgroundHex?: string +): PixelImage { + const l = Math.max(0, Math.trunc(left)); + const t = Math.max(0, Math.trunc(top)); + const r = Math.max(0, Math.trunc(right)); + const b = Math.max(0, Math.trunc(bottom)); + const out = createPixelImage(img.width + l + r, img.height + t + b); + if (backgroundHex !== undefined) { + const [cr, cg, cb] = parseHex(backgroundHex); + for (let i = 0; i < out.data.length; i += 4) { + out.data[i] = cr; + out.data[i + 1] = cg; + out.data[i + 2] = cb; + out.data[i + 3] = 255; + } + } + for (let y = 0; y < img.height; y++) { + const srcStart = y * img.width * 4; + out.data.set( + img.data.subarray(srcStart, srcStart + img.width * 4), + ((y + t) * out.width + l) * 4 + ); + } + return out; +} + +export function tile(img: PixelImage, columns: number, rows: number): PixelImage { + const cols = Math.max(1, Math.trunc(columns)); + const rowsCount = Math.max(1, Math.trunc(rows)); + const out = createPixelImage(img.width * cols, img.height * rowsCount); + for (let ty = 0; ty < rowsCount; ty++) { + for (let tx = 0; tx < cols; tx++) { + for (let y = 0; y < img.height; y++) { + const srcStart = y * img.width * 4; + out.data.set( + img.data.subarray(srcStart, srcStart + img.width * 4), + ((ty * img.height + y) * out.width + tx * img.width) * 4 + ); + } + } + } + return out; +} + +export function centerByAlpha(img: PixelImage): PixelImage { + let minX = img.width; + let minY = img.height; + let maxX = -1; + let maxY = -1; + for (let y = 0; y < img.height; y++) { + for (let x = 0; x < img.width; x++) { + if (img.data[(y * img.width + x) * 4 + 3] > 0) { + if (x < minX) minX = x; + if (y < minY) minY = y; + if (x > maxX) maxX = x; + if (y > maxY) maxY = y; + } + } + } + if (maxX < 0) return clonePixelImage(img); + const content = crop(img, minX, minY, maxX - minX + 1, maxY - minY + 1); + const out = createPixelImage(img.width, img.height); + const dx = Math.floor((img.width - content.width) / 2); + const dy = Math.floor((img.height - content.height) / 2); + for (let y = 0; y < content.height; y++) { + out.data.set( + content.data.subarray(y * content.width * 4, (y + 1) * content.width * 4), + ((y + dy) * out.width + dx) * 4 + ); + } + return out; +} + export type FlipAxis = 'horizontal' | 'vertical'; export function flip(img: PixelImage, axis: FlipAxis): PixelImage { diff --git a/web/src/lib/registry.test.ts b/web/src/lib/registry.test.ts index 1c721ce..e34aa55 100644 --- a/web/src/lib/registry.test.ts +++ b/web/src/lib/registry.test.ts @@ -15,6 +15,11 @@ const PLAN_TOOL_IDS = [ 'crop-png', 'rotate-png', 'flip-png', + 'add-padding-png', + 'add-border-png', + 'fit-on-background-png', + 'tile-png', + 'center-by-alpha-png', 'grayscale-png', 'invert-colors-png', 'adjust-brightness-contrast-png', @@ -42,11 +47,15 @@ const PLAN_TOOL_IDS = [ 'swap-channels-png', 'black-and-white-png', 'posterize-png', - 'two-colors-png' + 'two-colors-png', + 'remove-alpha-channel-png', + 'set-alpha-channel-png', + 'extract-alpha-mask-png', + 'round-corners-png' ]; describe('реестр инструментов', () => { - it('содержит ровно 32 инструмента из плана', () => { + it('содержит ровно 41 инструмент из плана', () => { expect(TOOLS.map((t) => t.id).sort()).toEqual([...PLAN_TOOL_IDS].sort()); }); diff --git a/web/src/lib/registry.ts b/web/src/lib/registry.ts index 143a3a4..f900b89 100644 --- a/web/src/lib/registry.ts +++ b/web/src/lib/registry.ts @@ -1,5 +1,13 @@ import type { CategoryId } from './categories'; -import { colorMask, flattenOntoColor, invertAlpha, removeColorToAlpha } from './core/alpha'; +import { + colorMask, + extractAlphaMask, + flattenOntoColor, + invertAlpha, + removeColorToAlpha, + roundCorners, + setAlphaChannel +} from './core/alpha'; import { brightnessContrast, changeHue, @@ -15,7 +23,7 @@ import { type ChannelSwapPair, type RgbChannel } from './core/color'; -import { crop, flip, resize, rotate90 } from './core/geometry'; +import { centerByAlpha, crop, expandCanvas, flip, resize, rotate90, tile } from './core/geometry'; import { decodeTextImage, toBase64, toDataUrl, type OutputMime } from './core/io'; import { hexToPixels, pixelsToHex } from './core/text'; import { clonePixelImage, type PixelImage } from './core/types'; @@ -293,6 +301,87 @@ export const TOOLS: ToolEntry[] = [ ], run: (img, p) => flip(img, str(p, 'axis') === 'vertical' ? 'vertical' : 'horizontal') }, + { + id: 'add-padding-png', + title: 'Добавить поля PNG', + description: 'Расширяет холст во все стороны на выбранное число пикселей.', + category: 'geometry', + params: [ + { id: 'padding', label: 'Поля, px', type: 'number', min: 1, max: 2000, step: 1, default: 10 }, + { id: 'transparent', label: 'Прозрачные поля', type: 'checkbox', default: true }, + { id: 'color', label: 'Цвет полей', type: 'color', default: '#ffffff' } + ], + run: (img, p) => + expandCanvas( + img, + num(p, 'padding'), + num(p, 'padding'), + num(p, 'padding'), + num(p, 'padding'), + p['transparent'] === true ? undefined : str(p, 'color') + ) + }, + { + id: 'add-border-png', + title: 'Добавить рамку PNG', + description: 'Рисует цветную рамку вокруг изображения выбранной толщины.', + category: 'geometry', + params: [ + { id: 'thickness', label: 'Толщина рамки, px', type: 'number', min: 1, max: 500, step: 1, default: 5 }, + { id: 'color', label: 'Цвет рамки', type: 'color', default: '#000000' } + ], + run: (img, p) => expandCanvas(img, num(p, 'thickness'), num(p, 'thickness'), num(p, 'thickness'), num(p, 'thickness'), str(p, 'color')) + }, + { + id: 'fit-on-background-png', + title: 'Вписать PNG на фон', + description: + 'Помещает изображение по центру полотна заданного размера с прозрачным или цветным фоном.', + category: 'geometry', + 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: false }, + { id: 'color', label: 'Цвет фона', type: 'color', default: '#ffffff' } + ], + run: (img, p) => { + const width = Math.trunc(num(p, 'width')); + const height = Math.trunc(num(p, 'height')); + if (width <= 0 || height <= 0) { + throw new Error('Укажите положительные размеры полотна'); + } + const left = Math.max(0, Math.floor((width - img.width) / 2)); + const top = Math.max(0, Math.floor((height - img.height) / 2)); + return expandCanvas( + img, + left, + top, + Math.max(0, width - img.width - left), + Math.max(0, height - img.height - top), + p['transparent'] === true ? undefined : str(p, 'color') + ); + } + }, + { + id: 'tile-png', + title: 'Замостить PNG', + description: 'Повторяет изображение сеткой из выбранного числа столбцов и строк.', + category: 'geometry', + params: [ + { id: 'columns', label: 'Столбцов', type: 'number', min: 1, max: 50, step: 1, default: 2 }, + { id: 'rows', label: 'Строк', type: 'number', min: 1, max: 50, step: 1, default: 2 } + ], + run: (img, p) => tile(img, num(p, 'columns'), num(p, 'rows')) + }, + { + id: 'center-by-alpha-png', + title: 'Центрировать PNG по содержимому', + description: + 'Находит непрозрачную часть изображения и размещает её по центру прежнего холста.', + category: 'geometry', + params: [], + run: (img) => centerByAlpha(img) + }, { id: 'grayscale-png', title: 'Чёрно-белый PNG', @@ -443,6 +532,43 @@ export const TOOLS: ToolEntry[] = [ output: { mime: 'image/webp', ext: 'webp', qualityParamId: 'quality' }, run: (img) => clonePixelImage(img) }, + { + id: 'remove-alpha-channel-png', + title: 'Убрать альфа-канал PNG', + description: 'Накладывает изображение на белый фон и сохраняет без прозрачности.', + category: 'alpha', + params: [], + run: (img) => flattenOntoColor(img, '#ffffff') + }, + { + id: 'set-alpha-channel-png', + title: 'Задать альфа-канал PNG', + description: 'Присваивает всем пикселям одинаковую прозрачность, цвета не меняются.', + category: 'alpha', + params: [ + { id: 'percent', label: 'Прозрачность, %', type: 'slider', min: 0, max: 100, step: 1, default: 100 } + ], + run: (img, p) => setAlphaChannel(img, num(p, 'percent')) + }, + { + id: 'extract-alpha-mask-png', + title: 'Извлечь маску альфы PNG', + description: 'Превращает прозрачность в чёрно-белую непрозрачную маску.', + category: 'alpha', + params: [], + run: (img) => extractAlphaMask(img) + }, + { + id: 'round-corners-png', + title: 'Скруглить углы PNG', + description: + 'Обрезает углы по радиусу, заданному в процентах от половины меньшей стороны.', + category: 'alpha', + params: [ + { id: 'radius', label: 'Радиус скругления, %', type: 'slider', min: 0, max: 50, step: 1, default: 10 } + ], + run: (img, p) => roundCorners(img, num(p, 'radius')) + }, { id: 'invert-alpha-png', title: 'Инвертировать альфа-канал PNG',