feat: add core operations

This commit is contained in:
2026-08-23 11:14:33 +05:00
parent 17b0c68eb5
commit 7fa2c52bb8
6 changed files with 435 additions and 7 deletions
+8 -1
View File
@@ -1,7 +1,14 @@
import { describe, expect, it } from 'vitest'; 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'; 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', () => { describe('removeColorToAlpha', () => {
const fixture = () => const fixture = () =>
makeImage(2, 1, [ makeImage(2, 1, [
+11
View File
@@ -22,6 +22,17 @@ export function removeColorToAlpha(
return out; 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 { export function colorMask(img: PixelImage, hex: string, tolerancePercent = 0): PixelImage {
const [targetR, targetG, targetB] = parseHex(hex); const [targetR, targetG, targetB] = parseHex(hex);
const tolerance = (clamp(tolerancePercent, 0, 100) / 100) * MAX_COLOR_DISTANCE; const tolerance = (clamp(tolerancePercent, 0, 100) / 100) * MAX_COLOR_DISTANCE;
+106 -1
View File
@@ -1,5 +1,18 @@
import { describe, expect, it } from 'vitest'; 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'; import { makeImage } from './test-helpers';
describe('rgbToHex', () => { 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', () => { describe('grayscale', () => {
it('считает luma по весам BT.601 с округлением', () => { it('считает luma по весам BT.601 с округлением', () => {
const out = grayscale( const out = grayscale(
+174 -1
View File
@@ -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<RgbChannel, number> = { 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<ChannelSwapPair, [number, number]> = {
'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 { export function grayscale(img: PixelImage): PixelImage {
const out = createPixelImage(img.width, img.height); const out = createPixelImage(img.width, img.height);
+11 -2
View File
@@ -22,6 +22,7 @@ const PLAN_TOOL_IDS = [
'convert-png-to-webp', 'convert-png-to-webp',
'remove-color-from-png', 'remove-color-from-png',
'png-info', 'png-info',
'invert-alpha-png',
'jpg-to-png', 'jpg-to-png',
'webp-to-png', 'webp-to-png',
'gif-to-png', 'gif-to-png',
@@ -33,11 +34,19 @@ const PLAN_TOOL_IDS = [
'png-to-data-uri', 'png-to-data-uri',
'data-uri-to-png', 'data-uri-to-png',
'png-to-hex', '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('реестр инструментов', () => { describe('реестр инструментов', () => {
it('содержит ровно 23 инструмента из плана', () => { it('содержит ровно 32 инструмента из плана', () => {
expect(TOOLS.map((t) => t.id).sort()).toEqual([...PLAN_TOOL_IDS].sort()); expect(TOOLS.map((t) => t.id).sort()).toEqual([...PLAN_TOOL_IDS].sort());
}); });
+125 -2
View File
@@ -1,6 +1,20 @@
import type { CategoryId } from './categories'; import type { CategoryId } from './categories';
import { colorMask, flattenOntoColor, removeColorToAlpha } from './core/alpha'; import { colorMask, flattenOntoColor, invertAlpha, removeColorToAlpha } from './core/alpha';
import { brightnessContrast, grayscale, invert } from './core/color'; 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 { crop, flip, resize, rotate90 } from './core/geometry';
import { decodeTextImage, toBase64, toDataUrl, type OutputMime } from './core/io'; import { decodeTextImage, toBase64, toDataUrl, type OutputMime } from './core/io';
import { hexToPixels, pixelsToHex } from './core/text'; 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')) 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', id: 'convert-png-to-jpg',
title: 'Конвертировать PNG в JPG', title: 'Конвертировать PNG в JPG',
@@ -328,6 +443,14 @@ export const TOOLS: ToolEntry[] = [
output: { mime: 'image/webp', ext: 'webp', qualityParamId: 'quality' }, output: { mime: 'image/webp', ext: 'webp', qualityParamId: 'quality' },
run: (img) => clonePixelImage(img) run: (img) => clonePixelImage(img)
}, },
{
id: 'invert-alpha-png',
title: 'Инвертировать альфа-канал PNG',
description: 'Непрозрачные области становятся прозрачными и наоборот.',
category: 'alpha',
params: [],
run: (img) => invertAlpha(img)
},
{ {
id: 'remove-color-from-png', id: 'remove-color-from-png',
title: 'Удалить цвет из PNG (прозрачность)', title: 'Удалить цвет из PNG (прозрачность)',