From 7fa2c52bb83538898398460de5ed520a26372c02 Mon Sep 17 00:00:00 2001 From: Ku6epXBOCTuK Date: Sun, 23 Aug 2026 11:14:33 +0500 Subject: [PATCH] feat: add core operations --- web/src/lib/core/alpha.test.ts | 9 +- web/src/lib/core/alpha.ts | 11 +++ web/src/lib/core/color.test.ts | 107 +++++++++++++++++++- web/src/lib/core/color.ts | 175 ++++++++++++++++++++++++++++++++- web/src/lib/registry.test.ts | 13 ++- web/src/lib/registry.ts | 127 +++++++++++++++++++++++- 6 files changed, 435 insertions(+), 7 deletions(-) diff --git a/web/src/lib/core/alpha.test.ts b/web/src/lib/core/alpha.test.ts index 34c5e9f..da14499 100644 --- a/web/src/lib/core/alpha.test.ts +++ b/web/src/lib/core/alpha.test.ts @@ -1,7 +1,14 @@ import { describe, expect, it } from 'vitest'; -import { colorMask, flattenOntoColor, parseHex, removeColorToAlpha } from './alpha'; +import { colorMask, flattenOntoColor, invertAlpha, parseHex, removeColorToAlpha } from './alpha'; import { makeImage } from './test-helpers'; +describe('invertAlpha', () => { + it('обращает альфу, RGB не трогает', () => { + const out = invertAlpha(makeImage(1, 1, [[10, 20, 30, 128]])); + expect([...out.data]).toEqual([10, 20, 30, 127]); + }); +}); + describe('removeColorToAlpha', () => { const fixture = () => makeImage(2, 1, [ diff --git a/web/src/lib/core/alpha.ts b/web/src/lib/core/alpha.ts index 5f6fe6f..fc92b40 100644 --- a/web/src/lib/core/alpha.ts +++ b/web/src/lib/core/alpha.ts @@ -22,6 +22,17 @@ export function removeColorToAlpha( 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) { + out.data[i] = img.data[i]; + out.data[i + 1] = img.data[i + 1]; + out.data[i + 2] = img.data[i + 2]; + out.data[i + 3] = 255 - img.data[i + 3]; + } + return out; +} + export function colorMask(img: PixelImage, hex: string, tolerancePercent = 0): PixelImage { const [targetR, targetG, targetB] = parseHex(hex); const tolerance = (clamp(tolerancePercent, 0, 100) / 100) * MAX_COLOR_DISTANCE; diff --git a/web/src/lib/core/color.test.ts b/web/src/lib/core/color.test.ts index 5a56913..9fda8d8 100644 --- a/web/src/lib/core/color.test.ts +++ b/web/src/lib/core/color.test.ts @@ -1,5 +1,18 @@ import { describe, expect, it } from 'vitest'; -import { brightnessContrast, grayscale, invert, rgbToHex } from './color'; +import { + brightnessContrast, + changeHue, + extractChannel, + grayscale, + invert, + posterize, + rgbToHex, + sepia, + setOpacity, + swapChannels, + thresholdBlackWhite, + twoColors +} from './color'; import { makeImage } from './test-helpers'; describe('rgbToHex', () => { @@ -13,6 +26,98 @@ describe('rgbToHex', () => { }); }); +describe('setOpacity', () => { + it('умножает альфу на процент, RGB не трогает', () => { + const out = setOpacity(makeImage(1, 1, [[10, 20, 30, 128]]), 50); + expect([...out.data]).toEqual([10, 20, 30, 64]); + }); + + it('100% не меняет, 0% делает полностью прозрачным', () => { + expect(setOpacity(makeImage(1, 1, [[1, 2, 3, 200]]), 100).data[3]).toBe(200); + expect(setOpacity(makeImage(1, 1, [[1, 2, 3, 200]]), 0).data[3]).toBe(0); + }); +}); + +describe('sepia', () => { + it('применяет классическую матрицу с клампом', () => { + const out = sepia(makeImage(2, 1, [ + [255, 0, 0, 255], + [255, 255, 255, 255] + ])); + const px = (n: number) => [...out.data.slice(n * 4, n * 4 + 4)]; + expect(px(0)).toEqual([100, 89, 69, 255]); + expect(px(1)[0]).toBe(255); + expect(out.data[7]).toBe(255); + }); +}); + +describe('changeHue', () => { + it('чистый красный при +120° становится чистым зелёным', () => { + const out = changeHue(makeImage(1, 1, [[255, 0, 0, 255]]), 120); + expect([...out.data]).toEqual([0, 255, 0, 255]); + }); + + it('сдвиг 360° возвращает исходные цвета', () => { + const img = makeImage(1, 1, [[90, 140, 210, 255]]); + expect([...changeHue(img, 360).data]).toEqual([...img.data]); + }); +}); + +describe('extractChannel', () => { + it('выдаёт выбранный канал оттенками серого', () => { + const out = extractChannel(makeImage(1, 1, [[10, 20, 30, 40]]), 'green'); + expect([...out.data]).toEqual([20, 20, 20, 40]); + }); +}); + +describe('swapChannels', () => { + it('переставляет каналы парами', () => { + expect([...swapChannels(makeImage(1, 1, [[10, 20, 30, 40]]), 'r-b').data]).toEqual([ + 30, 20, 10, 40 + ]); + expect([...swapChannels(makeImage(1, 1, [[10, 20, 30, 40]]), 'g-b').data]).toEqual([ + 10, 30, 20, 40 + ]); + }); +}); + +describe('thresholdBlackWhite', () => { + it('серый 128 относительно порога 50% — белый', () => { + expect(thresholdBlackWhite(makeImage(1, 1, [[128, 128, 128, 255]]), 50).data[0]).toBe(255); + expect(thresholdBlackWhite(makeImage(1, 1, [[128, 128, 128, 255]]), 60).data[0]).toBe(0); + }); +}); + +describe('posterize', () => { + it('два уровня квантуют в чёрное и белое', () => { + const out = posterize( + makeImage(2, 1, [ + [100, 100, 100, 255], + [200, 200, 200, 255] + ]), + 2 + ); + expect(out.data[0]).toBe(0); + expect(out.data[4]).toBe(255); + }); +}); + +describe('twoColors', () => { + it('яркие пиксели получают светлый цвет, тёмные — тёмный', () => { + const out = twoColors( + makeImage(2, 1, [ + [250, 250, 250, 255], + [10, 10, 10, 128] + ]), + '#ff0000', + '#00ff00', + 50 + ); + expect([...out.data.slice(0, 4)]).toEqual([255, 0, 0, 255]); + expect([...out.data.slice(4, 8)]).toEqual([0, 255, 0, 128]); + }); +}); + describe('grayscale', () => { it('считает luma по весам BT.601 с округлением', () => { const out = grayscale( diff --git a/web/src/lib/core/color.ts b/web/src/lib/core/color.ts index d69cf33..5170809 100644 --- a/web/src/lib/core/color.ts +++ b/web/src/lib/core/color.ts @@ -1,4 +1,177 @@ -import { createPixelImage, type PixelImage } from './types'; +import { clonePixelImage, createPixelImage, type PixelImage } from './types'; + +export type RgbChannel = 'red' | 'green' | 'blue'; + +export type ChannelSwapPair = 'r-g' | 'r-b' | 'g-b'; + +export function setOpacity(img: PixelImage, percent: number): PixelImage { + const factor = clamp(percent, 0, 100) / 100; + const out = createPixelImage(img.width, img.height); + for (let i = 0; i < out.data.length; i += 4) { + out.data[i] = img.data[i]; + out.data[i + 1] = img.data[i + 1]; + out.data[i + 2] = img.data[i + 2]; + out.data[i + 3] = img.data[i + 3] * factor; + } + return out; +} + +export function sepia(img: PixelImage): PixelImage { + const out = createPixelImage(img.width, img.height); + for (let i = 0; i < out.data.length; i += 4) { + const r = img.data[i]; + const g = img.data[i + 1]; + const b = img.data[i + 2]; + out.data[i] = Math.min(255, 0.393 * r + 0.769 * g + 0.189 * b); + out.data[i + 1] = Math.min(255, 0.349 * r + 0.686 * g + 0.168 * b); + out.data[i + 2] = Math.min(255, 0.272 * r + 0.534 * g + 0.131 * b); + out.data[i + 3] = img.data[i + 3]; + } + return out; +} + +export function changeHue(img: PixelImage, degrees: number): PixelImage { + const shift = (((Math.round(degrees) % 360) + 360) % 360) / 360; + if (shift === 0) return clonePixelImage(img); + const out = createPixelImage(img.width, img.height); + for (let i = 0; i < out.data.length; i += 4) { + const rf = img.data[i] / 255; + const gf = img.data[i + 1] / 255; + const bf = img.data[i + 2] / 255; + const max = Math.max(rf, gf, bf); + const min = Math.min(rf, gf, bf); + const l = (max + min) / 2; + let h = 0; + let s = 0; + if (max !== min) { + const d = max - min; + s = l > 0.5 ? d / (2 - max - min) : d / (max + min); + if (max === rf) h = (gf - bf) / d + (gf < bf ? 6 : 0); + else if (max === gf) h = (bf - rf) / d + 2; + else h = (rf - gf) / d + 4; + h /= 6; + } + h = (h + shift) % 1; + const q = l < 0.5 ? l * (1 + s) : l + s - l * s; + const p = 2 * l - q; + out.data[i] = hueComponent(p, q, h + 1 / 3) * 255; + out.data[i + 1] = hueComponent(p, q, h) * 255; + out.data[i + 2] = hueComponent(p, q, h - 1 / 3) * 255; + out.data[i + 3] = img.data[i + 3]; + } + return out; +} + +function hueComponent(p: number, q: number, t: number): number { + if (t < 0) t += 1; + if (t > 1) t -= 1; + if (t < 1 / 6) return p + (q - p) * 6 * t; + if (t < 1 / 2) return q; + if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6; + return p; +} + +const CHANNEL_INDEX: Record = { red: 0, green: 1, blue: 2 }; + +export function extractChannel(img: PixelImage, channel: RgbChannel): PixelImage { + const index = CHANNEL_INDEX[channel]; + const out = createPixelImage(img.width, img.height); + for (let i = 0; i < out.data.length; i += 4) { + const v = img.data[i + index]; + out.data[i] = v; + out.data[i + 1] = v; + out.data[i + 2] = v; + out.data[i + 3] = img.data[i + 3]; + } + return out; +} + +const SWAP_INDEX: Record = { + 'r-g': [0, 1], + 'r-b': [0, 2], + 'g-b': [1, 2] +}; + +export function swapChannels(img: PixelImage, pair: ChannelSwapPair): PixelImage { + const [a, b] = SWAP_INDEX[pair]; + const out = createPixelImage(img.width, img.height); + for (let i = 0; i < out.data.length; i += 4) { + out.data[i] = img.data[i]; + out.data[i + 1] = img.data[i + 1]; + out.data[i + 2] = img.data[i + 2]; + out.data[i + 3] = img.data[i + 3]; + const tmp = out.data[i + a]; + out.data[i + a] = out.data[i + b]; + out.data[i + b] = tmp; + } + return out; +} + +export function thresholdBlackWhite(img: PixelImage, thresholdPercent: number): PixelImage { + const threshold = (clamp(thresholdPercent, 0, 100) / 100) * 255; + const out = createPixelImage(img.width, img.height); + for (let i = 0; i < out.data.length; i += 4) { + const luma = 0.299 * img.data[i] + 0.587 * img.data[i + 1] + 0.114 * img.data[i + 2]; + const v = luma >= threshold ? 255 : 0; + out.data[i] = v; + out.data[i + 1] = v; + out.data[i + 2] = v; + out.data[i + 3] = img.data[i + 3]; + } + return out; +} + +export function posterize(img: PixelImage, levels: number): PixelImage { + const n = clamp(Math.round(levels), 2, 256); + const stepSize = 255 / (n - 1); + const out = createPixelImage(img.width, img.height); + for (let i = 0; i < out.data.length; i += 4) { + for (let ch = 0; ch < 3; ch++) { + out.data[i + ch] = Math.round(Math.round(img.data[i + ch] / stepSize) * stepSize); + } + out.data[i + 3] = img.data[i + 3]; + } + return out; +} + +export function twoColors( + img: PixelImage, + lightHex: string, + darkHex: string, + thresholdPercent: number +): PixelImage { + const [lr, lg, lb] = parseColor(lightHex); + const [dr, dg, db] = parseColor(darkHex); + const threshold = (clamp(thresholdPercent, 0, 100) / 100) * 255; + const out = createPixelImage(img.width, img.height); + for (let i = 0; i < out.data.length; i += 4) { + const luma = 0.299 * img.data[i] + 0.587 * img.data[i + 1] + 0.114 * img.data[i + 2]; + if (luma >= threshold) { + out.data[i] = lr; + out.data[i + 1] = lg; + out.data[i + 2] = lb; + } else { + out.data[i] = dr; + out.data[i + 1] = dg; + out.data[i + 2] = db; + } + out.data[i + 3] = img.data[i + 3]; + } + return out; +} + +function parseColor(hex: string): [number, number, number] { + const match = /^#([0-9a-f]{6})$/i.exec(hex.trim()); + if (!match) { + throw new Error(`Некорректный HEX-цвет: "${hex}"`); + } + const digits = match[1]; + return [ + parseInt(digits.slice(0, 2), 16), + parseInt(digits.slice(2, 4), 16), + parseInt(digits.slice(4, 6), 16) + ]; +} export function grayscale(img: PixelImage): PixelImage { const out = createPixelImage(img.width, img.height); diff --git a/web/src/lib/registry.test.ts b/web/src/lib/registry.test.ts index d37743f..1c721ce 100644 --- a/web/src/lib/registry.test.ts +++ b/web/src/lib/registry.test.ts @@ -22,6 +22,7 @@ const PLAN_TOOL_IDS = [ 'convert-png-to-webp', 'remove-color-from-png', 'png-info', + 'invert-alpha-png', 'jpg-to-png', 'webp-to-png', 'gif-to-png', @@ -33,11 +34,19 @@ const PLAN_TOOL_IDS = [ 'png-to-data-uri', 'data-uri-to-png', 'png-to-hex', - 'hex-to-png' + 'hex-to-png', + 'change-png-opacity', + 'sepia-png', + 'change-png-hue', + 'extract-channel-png', + 'swap-channels-png', + 'black-and-white-png', + 'posterize-png', + 'two-colors-png' ]; describe('реестр инструментов', () => { - it('содержит ровно 23 инструмента из плана', () => { + it('содержит ровно 32 инструмента из плана', () => { 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 9946925..143a3a4 100644 --- a/web/src/lib/registry.ts +++ b/web/src/lib/registry.ts @@ -1,6 +1,20 @@ import type { CategoryId } from './categories'; -import { colorMask, flattenOntoColor, removeColorToAlpha } from './core/alpha'; -import { brightnessContrast, grayscale, invert } from './core/color'; +import { colorMask, flattenOntoColor, invertAlpha, removeColorToAlpha } from './core/alpha'; +import { + brightnessContrast, + changeHue, + extractChannel, + grayscale, + invert, + posterize, + sepia, + setOpacity, + swapChannels, + thresholdBlackWhite, + twoColors, + type ChannelSwapPair, + type RgbChannel +} from './core/color'; import { crop, flip, resize, rotate90 } from './core/geometry'; import { decodeTextImage, toBase64, toDataUrl, type OutputMime } from './core/io'; import { hexToPixels, pixelsToHex } from './core/text'; @@ -306,6 +320,107 @@ export const TOOLS: ToolEntry[] = [ ], run: (img, p) => brightnessContrast(img, num(p, 'brightness'), num(p, 'contrast')) }, + { + id: 'change-png-opacity', + title: 'Изменить прозрачность PNG', + description: + 'Умножает альфа-канал на процент: 0% — полностью прозрачный, 100% — без изменений.', + category: 'color', + params: [ + { id: 'percent', label: 'Прозрачность, %', type: 'slider', min: 0, max: 100, step: 1, default: 100 } + ], + run: (img, p) => setOpacity(img, num(p, 'percent')) + }, + { + id: 'sepia-png', + title: 'Эффект сепии', + description: 'Тонирует изображение в тёплые коричневые тона классической сепии.', + category: 'color', + params: [], + run: (img) => sepia(img) + }, + { + id: 'change-png-hue', + title: 'Сменить оттенок PNG', + description: 'Сдвиг цветового тона по кругу. Насыщенность и яркость сохраняются.', + category: 'color', + params: [ + { id: 'degrees', label: 'Сдвиг тона, °', type: 'slider', min: -180, max: 180, step: 1, default: 0 } + ], + run: (img, p) => changeHue(img, num(p, 'degrees')) + }, + { + id: 'extract-channel-png', + title: 'Извлечь канал PNG', + description: 'Оставляет выбранный канал — красный, зелёный или синий — в оттенках серого.', + category: 'color', + params: [ + { + id: 'channel', + label: 'Канал', + type: 'select', + default: 'red', + options: [ + { value: 'red', label: 'Красный' }, + { value: 'green', label: 'Зелёный' }, + { value: 'blue', label: 'Синий' } + ] + } + ], + run: (img, p) => extractChannel(img, str(p, 'channel') as RgbChannel) + }, + { + id: 'swap-channels-png', + title: 'Переставить каналы PNG', + description: 'Меняет местами два цветовых канала — быстрый способ получить необычный окрас.', + category: 'color', + params: [ + { + id: 'pair', + label: 'Пара каналов', + type: 'select', + default: 'r-g', + options: [ + { value: 'r-g', label: 'Красный ↔ Зелёный' }, + { value: 'r-b', label: 'Красный ↔ Синий' }, + { value: 'g-b', label: 'Зелёный ↔ Синий' } + ] + } + ], + run: (img, p) => swapChannels(img, str(p, 'pair') as ChannelSwapPair) + }, + { + id: 'black-and-white-png', + title: 'Чёрно-белый PNG по порогу', + description: 'Жёсткая бинаризация по яркости: каждый пиксель становится чёрным или белым.', + category: 'color', + params: [ + { id: 'threshold', label: 'Порог яркости, %', type: 'slider', min: 0, max: 100, step: 1, default: 50 } + ], + run: (img, p) => thresholdBlackWhite(img, num(p, 'threshold')) + }, + { + id: 'posterize-png', + title: 'Постеризация PNG', + description: 'Уменьшает число уровней каждого канала — плакатный эффект.', + category: 'color', + params: [ + { id: 'levels', label: 'Уровней на канал', type: 'slider', min: 2, max: 16, step: 1, default: 4 } + ], + run: (img, p) => posterize(img, num(p, 'levels')) + }, + { + id: 'two-colors-png', + title: 'Два цвета PNG', + description: 'Перекрашивает изображение в два выбранных цвета по порогу яркости.', + category: 'color', + params: [ + { id: 'lightColor', label: 'Цвет светлых участков', type: 'color', default: '#ffffff' }, + { id: 'darkColor', label: 'Цвет тёмных участков', type: 'color', default: '#000000' }, + { id: 'threshold', label: 'Порог яркости, %', type: 'slider', min: 0, max: 100, step: 1, default: 50 } + ], + run: (img, p) => twoColors(img, str(p, 'lightColor'), str(p, 'darkColor'), num(p, 'threshold')) + }, { id: 'convert-png-to-jpg', title: 'Конвертировать PNG в JPG', @@ -328,6 +443,14 @@ export const TOOLS: ToolEntry[] = [ output: { mime: 'image/webp', ext: 'webp', qualityParamId: 'quality' }, run: (img) => clonePixelImage(img) }, + { + id: 'invert-alpha-png', + title: 'Инвертировать альфа-канал PNG', + description: 'Непрозрачные области становятся прозрачными и наоборот.', + category: 'alpha', + params: [], + run: (img) => invertAlpha(img) + }, { id: 'remove-color-from-png', title: 'Удалить цвет из PNG (прозрачность)',