feat: add core operations
This commit is contained in:
@@ -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, [
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(
|
||||
|
||||
+174
-1
@@ -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 {
|
||||
const out = createPixelImage(img.width, img.height);
|
||||
|
||||
@@ -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());
|
||||
});
|
||||
|
||||
|
||||
+125
-2
@@ -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 (прозрачность)',
|
||||
|
||||
Reference in New Issue
Block a user