mirror of
https://github.com/Ku6epXBOCTuK/easy-png-tools.git
synced 2026-09-14 21:46:35 +00:00
feat: add pallette tools
This commit is contained in:
@@ -0,0 +1,138 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
analogousSet,
|
||||
complementarySet,
|
||||
hexToRgb,
|
||||
hslToRgb,
|
||||
monochromaticSet,
|
||||
mixColors,
|
||||
normalizeHex,
|
||||
parseHexList,
|
||||
renderBlend,
|
||||
renderSwatches,
|
||||
renderWheel,
|
||||
rgbToHsl,
|
||||
rgbToHex,
|
||||
shadeSet,
|
||||
sortPalette,
|
||||
triadicSet,
|
||||
tetradicSet
|
||||
} from './palette';
|
||||
|
||||
describe('конвертация rgb↔hsl', () => {
|
||||
it('красный → hsl(0,100%,50%) и обратно', () => {
|
||||
const hsl = rgbToHsl(hexToRgb('#ff0000'));
|
||||
expect(hsl.h).toBeCloseTo(0, 0);
|
||||
expect(hsl.s).toBeCloseTo(1, 5);
|
||||
expect(hsl.l).toBeCloseTo(0.5, 5);
|
||||
expect(rgbToHex(hslToRgb({ h: 0, s: 1, l: 0.5 }))).toBe('#ff0000');
|
||||
});
|
||||
|
||||
it('серые не имеют оттенка', () => {
|
||||
expect(rgbToHsl(hexToRgb('#808080')).s).toBe(0);
|
||||
});
|
||||
|
||||
it('круговой переход 360° возвращает исходный цвет', () => {
|
||||
const base = '#3b82f6';
|
||||
expect(rgbToHex(hslToRgb({ ...rgbToHsl(hexToRgb(base)), h: 720 + 30 }))).toBe(
|
||||
rgbToHex(hslToRgb({ ...rgbToHsl(hexToRgb(base)), h: 30 }))
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('гармонии', () => {
|
||||
it('complementary — пара с сдвигом 180°', () => {
|
||||
const [a, b] = complementarySet('#ff0000');
|
||||
expect(a).toBe('#ff0000');
|
||||
const ha = rgbToHsl(hexToRgb(a)).h;
|
||||
const hb = rgbToHsl(hexToRgb(b)).h;
|
||||
expect(Math.abs((hb - ha + 360) % 360)).toBeCloseTo(180, 0);
|
||||
});
|
||||
|
||||
it('triadic — три цвета через 120°', () => {
|
||||
const set = triadicSet('#ff0000');
|
||||
expect(set).toHaveLength(3);
|
||||
const hues = set.map((h) => rgbToHsl(hexToRgb(h)).h);
|
||||
expect(hues[1] - hues[0]).toBeCloseTo(120, 0);
|
||||
expect(hues[2] - hues[0]).toBeCloseTo(240, 0);
|
||||
});
|
||||
|
||||
it('tetradic — четыре цвета через 90°', () => {
|
||||
const set = tetradicSet('#00ff00');
|
||||
expect(set).toHaveLength(4);
|
||||
});
|
||||
|
||||
it('analogous симметричен вокруг базы', () => {
|
||||
const set = analogousSet('#0000ff', 30, 5);
|
||||
expect(set).toHaveLength(5);
|
||||
expect(set[2]).toBe(normalizeHex('#0000ff'));
|
||||
});
|
||||
|
||||
it('monochromatic держит оттенок, меняет светлоту', () => {
|
||||
const set = monochromaticSet('#ff8800', 5, 60);
|
||||
expect(set).toHaveLength(5);
|
||||
const hues = new Set(set.map((h) => Math.round(rgbToHsl(hexToRgb(h)).h)));
|
||||
expect(hues.size).toBe(1);
|
||||
const lights = set.map((h) => rgbToHsl(hexToRgb(h)).l);
|
||||
expect(Math.min(...lights)).toBeLessThan(Math.max(...lights));
|
||||
});
|
||||
|
||||
it('shades — тёмный край темнее базы', () => {
|
||||
const set = shadeSet('#88cc44', 4, 70);
|
||||
const lights = set.map((h) => rgbToHsl(hexToRgb(h)).l);
|
||||
expect(lights[lights.length - 1]).toBeLessThan(lights[0]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseHexList / mixColors / sortPalette', () => {
|
||||
it('парсит список и отбрасывает мусор и токены без решётки', () => {
|
||||
expect(parseHexList('#ff0000, #00FF00 ; 00ff00 zz')).toEqual(['#ff0000', '#00ff00']);
|
||||
});
|
||||
|
||||
it('пустой валидный список бросает badHex', () => {
|
||||
expect(() => parseHexList('нет цветов')).toThrow(/errors\.badHex/);
|
||||
});
|
||||
|
||||
it('mixColors — среднее компонент', () => {
|
||||
expect(mixColors(['#000000', '#ffffff'])).toBe('#808080');
|
||||
});
|
||||
|
||||
it('sortPalette по luma ставит тёмные раньше', () => {
|
||||
const sorted = sortPalette(['#ffffff', '#000000', '#808080'], 'luma');
|
||||
expect(sorted[0]).toBe('#000000');
|
||||
expect(sorted[2]).toBe('#ffffff');
|
||||
});
|
||||
});
|
||||
|
||||
describe('рендеры', () => {
|
||||
it('renderSwatches strip: колонки по цветам', () => {
|
||||
const img = renderSwatches(['#ff0000', '#00ff00'], 200, 'strip');
|
||||
expect(img.width).toBe(200);
|
||||
expect(img.data[(Math.floor(img.height / 2) * 200 + 10) * 4 + 3]).toBe(255);
|
||||
expect(img.data[(Math.floor(img.height / 2) * 200 + 10) * 4]).toBeGreaterThan(200);
|
||||
const right = (Math.floor(img.height / 2) * 200 + 150) * 4;
|
||||
expect(img.data[right]).toBeLessThan(50);
|
||||
expect(img.data[right + 1]).toBeGreaterThan(200);
|
||||
});
|
||||
|
||||
it('renderSwatches grid: квадратные ячейки', () => {
|
||||
const img = renderSwatches(['#111111', '#222222', '#333333'], 120, 'grid');
|
||||
expect(img.width).toBe(120);
|
||||
expect(img.height).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('renderWheel: углы прозрачны, центр непрозрачен', () => {
|
||||
const size = 101;
|
||||
const img = renderWheel(size, 50);
|
||||
expect(img.data[3]).toBe(0);
|
||||
const c = ((Math.floor(size / 2) * size) + Math.floor(size / 2)) * 4;
|
||||
expect(img.data[c + 3]).toBe(255);
|
||||
});
|
||||
|
||||
it('renderBlend: левый край = a, правый = b', () => {
|
||||
const img = renderBlend('#000000', '#ffffff', 100);
|
||||
expect(img.data[0]).toBe(0);
|
||||
const last = (99 * 4);
|
||||
expect(img.data[last]).toBe(255);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,264 @@
|
||||
import { ToolError } from './errors';
|
||||
import type { PixelImage } from './types';
|
||||
import { createPixelImage } from './types';
|
||||
|
||||
export type Rgb = { r: number; g: number; b: number };
|
||||
export type Hsl = { h: number; s: number; l: number };
|
||||
|
||||
export function hexToRgb(hex: string): Rgb {
|
||||
const m = /^#([0-9a-f]{6})$/i.exec(hex.trim());
|
||||
if (!m) throw new ToolError('errors.badHex', { value: hex });
|
||||
const d = m[1];
|
||||
return {
|
||||
r: parseInt(d.slice(0, 2), 16),
|
||||
g: parseInt(d.slice(2, 4), 16),
|
||||
b: parseInt(d.slice(4, 6), 16)
|
||||
};
|
||||
}
|
||||
|
||||
const byte = (v: number) =>
|
||||
Math.round(Math.min(255, Math.max(0, v)))
|
||||
.toString(16)
|
||||
.padStart(2, '0');
|
||||
|
||||
export function rgbToHex({ r, g, b }: Rgb): string {
|
||||
return `#${byte(r)}${byte(g)}${byte(b)}`;
|
||||
}
|
||||
|
||||
/** h ∈ [0..360), s,l ∈ [0..1] */
|
||||
export function rgbToHsl({ r, g, b }: Rgb): Hsl {
|
||||
const rn = r / 255;
|
||||
const gn = g / 255;
|
||||
const bn = b / 255;
|
||||
const max = Math.max(rn, gn, bn);
|
||||
const min = Math.min(rn, gn, bn);
|
||||
const l = (max + min) / 2;
|
||||
if (max === min) return { h: 0, s: 0, l };
|
||||
const d = max - min;
|
||||
const s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
|
||||
let h: number;
|
||||
if (max === rn) h = ((gn - bn) / d + (gn < bn ? 6 : 0)) * 60;
|
||||
else if (max === gn) h = ((bn - rn) / d + 2) * 60;
|
||||
else h = ((rn - gn) / d + 4) * 60;
|
||||
return { h, s, l };
|
||||
}
|
||||
|
||||
export function hslToRgb({ h, s, l }: Hsl): Rgb {
|
||||
const hn = ((h % 360) + 360) % 360;
|
||||
const c = (1 - Math.abs(2 * l - 1)) * s;
|
||||
const x = c * (1 - Math.abs(((hn / 60) % 2) - 1));
|
||||
const m = l - c / 2;
|
||||
let r = 0;
|
||||
let g = 0;
|
||||
let b = 0;
|
||||
if (hn < 60) [r, g, b] = [c, x, 0];
|
||||
else if (hn < 120) [r, g, b] = [x, c, 0];
|
||||
else if (hn < 180) [r, g, b] = [0, c, x];
|
||||
else if (hn < 240) [r, g, b] = [0, x, c];
|
||||
else if (hn < 300) [r, g, b] = [x, 0, c];
|
||||
else [r, g, b] = [c, 0, x];
|
||||
return {
|
||||
r: Math.round((r + m) * 255),
|
||||
g: Math.round((g + m) * 255),
|
||||
b: Math.round((b + m) * 255)
|
||||
};
|
||||
}
|
||||
|
||||
export function shiftHue(hex: string, deltaDeg: number): string {
|
||||
const hsl = rgbToHsl(hexToRgb(hex));
|
||||
return rgbToHex(hslToRgb({ ...hsl, h: hsl.h + deltaDeg }));
|
||||
}
|
||||
|
||||
function withLightness(hex: string, l: number): string {
|
||||
return rgbToHex(hslToRgb({ ...rgbToHsl(hexToRgb(hex)), l }));
|
||||
}
|
||||
|
||||
export function complementarySet(base: string): string[] {
|
||||
return [normalizeHex(base), shiftHue(base, 180)];
|
||||
}
|
||||
|
||||
export function triadicSet(base: string): string[] {
|
||||
return [normalizeHex(base), shiftHue(base, 120), shiftHue(base, 240)];
|
||||
}
|
||||
|
||||
export function tetradicSet(base: string): string[] {
|
||||
return [normalizeHex(base), shiftHue(base, 90), shiftHue(base, 180), shiftHue(base, 270)];
|
||||
}
|
||||
|
||||
export function analogousSet(base: string, spreadDeg: number, count: number): string[] {
|
||||
const n = Math.max(3, Math.min(9, Math.round(count)));
|
||||
const half = Math.floor(n / 2);
|
||||
return Array.from({ length: n }, (_, i) => shiftHue(base, (i - half) * spreadDeg));
|
||||
}
|
||||
|
||||
export function monochromaticSet(base: string, count: number, rangePercent: number): string[] {
|
||||
const n = Math.max(2, Math.min(9, Math.round(count)));
|
||||
const baseL = rgbToHsl(hexToRgb(normalizeHex(base))).l;
|
||||
const halfSpan = Math.min(0.495, rangePercent / 200);
|
||||
return Array.from({ length: n }, (_, i) => {
|
||||
const t = n === 1 ? 0.5 : i / (n - 1);
|
||||
const l = clamp01(baseL - halfSpan + t * halfSpan * 2);
|
||||
return withLightness(normalizeHex(base), l);
|
||||
});
|
||||
}
|
||||
|
||||
export function shadeSet(base: string, count: number, depthPercent: number): string[] {
|
||||
const n = Math.max(2, Math.min(9, Math.round(count)));
|
||||
const baseL = rgbToHsl(hexToRgb(normalizeHex(base))).l;
|
||||
const floorL = Math.max(0.03, baseL - depthPercent / 100);
|
||||
return Array.from({ length: n }, (_, i) => {
|
||||
const t = i / (n - 1);
|
||||
return withLightness(normalizeHex(base), baseL - (baseL - floorL) * t);
|
||||
});
|
||||
}
|
||||
|
||||
export function parseHexList(text: string): string[] {
|
||||
const list = text
|
||||
.split(/[,\s;]+/)
|
||||
.map((t) => t.trim())
|
||||
.filter((t) => /^#[0-9a-f]{6}$/i.test(t))
|
||||
.map((t) => normalizeHex(t));
|
||||
if (list.length === 0) throw new ToolError('errors.badHex', { value: text });
|
||||
return list;
|
||||
}
|
||||
|
||||
export function normalizeHex(hex: string): string {
|
||||
const m = /^#([0-9a-f]{6})$/i.exec(hex.trim());
|
||||
if (!m) throw new ToolError('errors.badHex', { value: hex });
|
||||
return `#${m[1].toLowerCase()}`;
|
||||
}
|
||||
|
||||
export function mixColors(hexes: string[]): string {
|
||||
const sum = hexes
|
||||
.map(hexToRgb)
|
||||
.reduce((acc, c) => ({ r: acc.r + c.r, g: acc.g + c.g, b: acc.b + c.b }), { r: 0, g: 0, b: 0 });
|
||||
return rgbToHex({
|
||||
r: sum.r / hexes.length,
|
||||
g: sum.g / hexes.length,
|
||||
b: sum.b / hexes.length
|
||||
});
|
||||
}
|
||||
|
||||
export function luma(hex: string): number {
|
||||
const { r, g, b } = hexToRgb(hex);
|
||||
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
||||
}
|
||||
|
||||
export type SortKey = 'hue' | 'luma' | 'sat';
|
||||
|
||||
export function sortPalette(hexes: string[], key: SortKey): string[] {
|
||||
const scored = hexes.map((h) => {
|
||||
if (key === 'luma') return { h, k: luma(h) };
|
||||
const hsl = rgbToHsl(hexToRgb(h));
|
||||
return { h, k: key === 'hue' ? hsl.h : hsl.s };
|
||||
});
|
||||
return scored.sort((a, b) => a.k - b.k || a.h.localeCompare(b.h)).map((s) => s.h);
|
||||
}
|
||||
|
||||
function clamp01(v: number): number {
|
||||
return Math.min(1, Math.max(0, v));
|
||||
}
|
||||
|
||||
/** Горизонтальные равные колонки-свотчи (strip) или сетка ~квадратных ячеек (grid). */
|
||||
export function renderSwatches(colors: string[], width: number, layout: 'strip' | 'grid'): PixelImage {
|
||||
const n = colors.length;
|
||||
if (layout === 'strip') {
|
||||
const cellW = width / n;
|
||||
const height = Math.max(24, Math.round(cellW));
|
||||
const out = createPixelImage(width, height);
|
||||
colors.forEach((hex, i) => {
|
||||
const { r, g, b } = hexToRgb(hex);
|
||||
fillRect(out, Math.floor(i * cellW), 0, Math.ceil(cellW), height, r, g, b);
|
||||
});
|
||||
return out;
|
||||
}
|
||||
const cols = Math.max(1, Math.ceil(Math.sqrt(n)));
|
||||
const rows = Math.max(1, Math.ceil(n / cols));
|
||||
const cell = Math.floor(width / cols);
|
||||
const height = cell * rows;
|
||||
const out = createPixelImage(width, height);
|
||||
colors.forEach((hex, i) => {
|
||||
const { r, g, b } = hexToRgb(hex);
|
||||
fillRect(out, (i % cols) * cell, Math.floor(i / cols) * cell, cell, cell, r, g, b);
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
export function renderWheel(size: number, lightness: number): PixelImage {
|
||||
const out = createPixelImage(size, size);
|
||||
const c = (size - 1) / 2;
|
||||
const radius = c;
|
||||
for (let y = 0; y < size; y++) {
|
||||
for (let x = 0; x < size; x++) {
|
||||
const dx = x - c;
|
||||
const dy = y - c;
|
||||
const dist = Math.sqrt(dx * dx + dy * dy);
|
||||
const di = (y * size + x) * 4;
|
||||
if (dist > radius) continue;
|
||||
const h = ((Math.atan2(dy, dx) * 180) / Math.PI + 360) % 360;
|
||||
const s = dist / radius;
|
||||
const { r, g, b } = hslToRgb({ h, s, l: lightness / 100 });
|
||||
out.data[di] = r;
|
||||
out.data[di + 1] = g;
|
||||
out.data[di + 2] = b;
|
||||
out.data[di + 3] = 255;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function renderBlend(a: string, b: string, width: number): PixelImage {
|
||||
const height = Math.max(24, Math.round(width / 4));
|
||||
const out = createPixelImage(width, height);
|
||||
const ca = hexToRgb(a);
|
||||
const cb = hexToRgb(b);
|
||||
for (let x = 0; x < width; x++) {
|
||||
const t = width === 1 ? 0 : x / (width - 1);
|
||||
fillRect(
|
||||
out,
|
||||
x,
|
||||
0,
|
||||
1,
|
||||
height,
|
||||
Math.round(ca.r + (cb.r - ca.r) * t),
|
||||
Math.round(ca.g + (cb.g - ca.g) * t),
|
||||
Math.round(ca.b + (cb.b - ca.b) * t)
|
||||
);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function stepColors(aHex: string, bHex: string, steps: number): string[] {
|
||||
const n = Math.max(2, Math.min(12, Math.round(steps)));
|
||||
const ca = hexToRgb(aHex);
|
||||
const cb = hexToRgb(bHex);
|
||||
return Array.from({ length: n }, (_, i) => {
|
||||
const t = i / (n - 1);
|
||||
return rgbToHex({
|
||||
r: ca.r + (cb.r - ca.r) * t,
|
||||
g: ca.g + (cb.g - ca.g) * t,
|
||||
b: ca.b + (cb.b - ca.b) * t
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function fillRect(
|
||||
img: PixelImage,
|
||||
x0: number,
|
||||
y0: number,
|
||||
w: number,
|
||||
h: number,
|
||||
r: number,
|
||||
g: number,
|
||||
b: number
|
||||
): void {
|
||||
for (let y = y0; y < Math.min(y0 + h, img.height); y++) {
|
||||
for (let x = x0; x < Math.min(x0 + w, img.width); x++) {
|
||||
const di = (y * img.width + x) * 4;
|
||||
img.data[di] = r;
|
||||
img.data[di + 1] = g;
|
||||
img.data[di + 2] = b;
|
||||
img.data[di + 3] = 255;
|
||||
}
|
||||
}
|
||||
}
|
||||
+104
-2
@@ -436,6 +436,109 @@ export const ru: Dict = {
|
||||
},
|
||||
options: { direction: { horizontal: 'По горизонтали', vertical: 'По вертикали' } }
|
||||
},
|
||||
'color-wheel-png': {
|
||||
title: 'Цветовой круг PNG',
|
||||
description:
|
||||
'Круг HSL: оттенок по окружности, насыщенность от центра к краю, выбранная светлота.',
|
||||
params: { width: 'Размер', lightness: 'Светлота, %' }
|
||||
},
|
||||
'complementary-png': {
|
||||
title: 'Комплементарная палитра PNG',
|
||||
description: 'Два противоположных цвета круга — базовый и его дополнение.',
|
||||
params: {
|
||||
baseColor: 'Базовый цвет',
|
||||
width: 'Ширина',
|
||||
layout: 'Раскладка'
|
||||
},
|
||||
options: { layout: { grid: 'Сетка', strip: 'Полоса' } }
|
||||
},
|
||||
'triadic-png': {
|
||||
title: 'Триадная палитра PNG',
|
||||
description: 'Три цвета через 120° друг от друга на цветовом круге.',
|
||||
params: { baseColor: 'Базовый цвет', width: 'Ширина', layout: 'Раскладка' },
|
||||
options: { layout: { grid: 'Сетка', strip: 'Полоса' } }
|
||||
},
|
||||
'tetradic-png': {
|
||||
title: 'Тетрадная палитра PNG',
|
||||
description: 'Четыре цвета — две комплементарные пары, шаг 90° по кругу.',
|
||||
params: { baseColor: 'Базовый цвет', width: 'Ширина', layout: 'Раскладка' },
|
||||
options: { layout: { grid: 'Сетка', strip: 'Полоса' } }
|
||||
},
|
||||
'analogous-png': {
|
||||
title: 'Аналоговая палитра PNG',
|
||||
description: 'Соседние оттенки вокруг базового — спокойная родственная гамма.',
|
||||
params: {
|
||||
baseColor: 'Базовый цвет',
|
||||
width: 'Ширина',
|
||||
layout: 'Раскладка',
|
||||
spread: 'Разброс оттенка, °',
|
||||
count: 'Сколько цветов'
|
||||
},
|
||||
options: { layout: { grid: 'Сетка', strip: 'Полоса' } }
|
||||
},
|
||||
'monochromatic-png': {
|
||||
title: 'Монохромная палитра PNG',
|
||||
description:
|
||||
'Тоны одного оттенка: меняется светлота в выбранном диапазоне, тон и насыщенность фиксированы.',
|
||||
params: {
|
||||
baseColor: 'Базовый цвет',
|
||||
width: 'Ширина',
|
||||
layout: 'Раскладка',
|
||||
count: 'Сколько цветов',
|
||||
range: 'Диапазон светлоты, %'
|
||||
},
|
||||
options: { layout: { grid: 'Сетка', strip: 'Полоса' } }
|
||||
},
|
||||
'shades-png': {
|
||||
title: 'Градация оттенка PNG',
|
||||
description: 'Ступени базового цвета от исходного к более тёмному.',
|
||||
params: {
|
||||
baseColor: 'Базовый цвет',
|
||||
width: 'Ширина',
|
||||
layout: 'Раскладка',
|
||||
count: 'Сколько цветов',
|
||||
depth: 'Глубина затемнения, %'
|
||||
},
|
||||
options: { layout: { grid: 'Сетка', strip: 'Полоса' } }
|
||||
},
|
||||
'mix-colors-png': {
|
||||
title: 'Смешать цвета PNG',
|
||||
description:
|
||||
'Усредняет несколько hex-цветов в один свотч. Введите значения через запятую; неверные токены пропускаются.',
|
||||
params: { colors: 'Цвета (hex через запятую)', width: 'Ширина' }
|
||||
},
|
||||
'blend-two-png': {
|
||||
title: 'Перелив двух цветов PNG',
|
||||
description: 'Непрерывный горизонтальный градиент между двумя цветами.',
|
||||
params: { colorA: 'Цвет A', colorB: 'Цвет B', width: 'Ширина' }
|
||||
},
|
||||
'step-colors-png': {
|
||||
title: 'Ступени между цветами PNG',
|
||||
description: 'Дискретный набор равномерно распределённых ступеней между двумя цветами.',
|
||||
params: {
|
||||
colorA: 'Цвет A',
|
||||
colorB: 'Цвет B',
|
||||
steps: 'Сколько ступеней',
|
||||
width: 'Ширина',
|
||||
layout: 'Раскладка'
|
||||
},
|
||||
options: { layout: { grid: 'Сетка', strip: 'Полоса' } }
|
||||
},
|
||||
'sort-colors-png': {
|
||||
title: 'Отсортировать цвета PNG',
|
||||
description:
|
||||
'Рисует ваш hex-список свотчами, отсортированными по тону, яркости или насыщенности. Неверные токены пропускаются.',
|
||||
params: {
|
||||
colors: 'Цвета (hex через запятую)',
|
||||
order: 'Сортировка',
|
||||
width: 'Ширина',
|
||||
layout: 'Раскладка'
|
||||
},
|
||||
options: {
|
||||
order: { hue: 'Оттенок', luma: 'Яркость', sat: 'Насыщенность' },
|
||||
layout: { grid: 'Сетка', strip: 'Полоса' }
|
||||
}
|
||||
},
|
||||
'png-is-grayscale': {
|
||||
title: 'Проверить: PNG монохромный?',
|
||||
description: 'Сообщает, состоит ли изображение только из оттенков серого.',
|
||||
@@ -488,8 +591,7 @@ export const ru: Dict = {
|
||||
}
|
||||
},
|
||||
'add-text-png': {
|
||||
title: 'Надпись на PNG',
|
||||
description:
|
||||
title: 'Надпись на PNG', description:
|
||||
'Рисует текст на изображении: шрифт, размер, цвет, жирность, позиция на сетке 3×3 и опциональная подложка.',
|
||||
params: {
|
||||
text: 'Текст',
|
||||
|
||||
@@ -61,6 +61,22 @@ import {
|
||||
import { getOverlay } from './tools/overlay-store.svelte';
|
||||
import { formatStamp } from './core/datefmt';
|
||||
import type { Position9 } from './core/textdraw';
|
||||
import {
|
||||
analogousSet,
|
||||
complementarySet,
|
||||
monochromaticSet,
|
||||
parseHexList,
|
||||
renderBlend,
|
||||
renderSwatches,
|
||||
renderWheel,
|
||||
shadeSet,
|
||||
sortPalette,
|
||||
stepColors,
|
||||
mixColors,
|
||||
tetradicSet,
|
||||
triadicSet,
|
||||
type SortKey
|
||||
} from './core/palette';
|
||||
import { hexToPixels, pixelsToHex } from './core/text';
|
||||
import { clonePixelImage, type PixelImage } from './core/types';
|
||||
|
||||
@@ -157,6 +173,31 @@ function bool(params: Record<string, unknown>, id: string): boolean {
|
||||
return v;
|
||||
}
|
||||
|
||||
function paletteParams(baseDefault: string) {
|
||||
return [
|
||||
{ id: 'baseColor', label: 'Base color', type: 'color' as const, default: baseDefault },
|
||||
{
|
||||
id: 'width',
|
||||
label: 'Width',
|
||||
type: 'slider' as const,
|
||||
min: 128,
|
||||
max: 1024,
|
||||
step: 16,
|
||||
default: 512
|
||||
},
|
||||
{
|
||||
id: 'layout',
|
||||
label: 'Layout',
|
||||
type: 'select' as const,
|
||||
default: 'grid',
|
||||
options: [
|
||||
{ value: 'grid', label: 'Grid' },
|
||||
{ value: 'strip', label: 'Strip' }
|
||||
]
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
function decodeToPng(id: string, title: string, description: string): ToolEntry {
|
||||
return {
|
||||
id,
|
||||
@@ -877,6 +918,187 @@ export const TOOLS: ToolEntry[] = [
|
||||
str(p, 'direction') === 'vertical' ? 'vertical' : 'horizontal'
|
||||
)
|
||||
},
|
||||
{
|
||||
id: 'color-wheel-png',
|
||||
title: 'Color Wheel PNG',
|
||||
description:
|
||||
'Generates an HSL color wheel: hue around the circle, saturation from center to edge, chosen lightness.',
|
||||
category: 'generate',
|
||||
sourceMode: 'none',
|
||||
params: [
|
||||
{ id: 'width', label: 'Size', type: 'slider', min: 128, max: 1024, step: 16, default: 512 },
|
||||
{ id: 'lightness', label: 'Lightness, %', type: 'slider', min: 0, max: 100, step: 1, default: 50 }
|
||||
],
|
||||
generate: (p) => renderWheel(Math.trunc(num(p, 'width')), num(p, 'lightness'))
|
||||
},
|
||||
{
|
||||
id: 'complementary-png',
|
||||
title: 'Complementary Palette PNG',
|
||||
description: 'Two opposite colors on the color wheel — the base and its complement.',
|
||||
category: 'generate',
|
||||
sourceMode: 'none',
|
||||
params: paletteParams('#2563eb'),
|
||||
generate: (p) =>
|
||||
renderSwatches(complementarySet(str(p, 'baseColor')), num(p, 'width'), str(p, 'layout') as 'strip' | 'grid')
|
||||
},
|
||||
{
|
||||
id: 'triadic-png',
|
||||
title: 'Triadic Palette PNG',
|
||||
description: 'Three colors evenly spaced 120° apart on the color wheel.',
|
||||
category: 'generate',
|
||||
sourceMode: 'none',
|
||||
params: paletteParams('#ff0000'),
|
||||
generate: (p) =>
|
||||
renderSwatches(triadicSet(str(p, 'baseColor')), num(p, 'width'), str(p, 'layout') as 'strip' | 'grid')
|
||||
},
|
||||
{
|
||||
id: 'tetradic-png',
|
||||
title: 'Tetradic Palette PNG',
|
||||
description: 'Four colors in two complementary pairs, 90° apart on the wheel.',
|
||||
category: 'generate',
|
||||
sourceMode: 'none',
|
||||
params: paletteParams('#8000ff'),
|
||||
generate: (p) =>
|
||||
renderSwatches(tetradicSet(str(p, 'baseColor')), num(p, 'width'), str(p, 'layout') as 'strip' | 'grid')
|
||||
},
|
||||
{
|
||||
id: 'analogous-png',
|
||||
title: 'Analogous Palette PNG',
|
||||
description: 'Neighboring hues around the base color — calm, related color scheme.',
|
||||
category: 'generate',
|
||||
sourceMode: 'none',
|
||||
params: [
|
||||
...paletteParams('#22c55e'),
|
||||
{ id: 'spread', label: 'Hue spread, °', type: 'slider', min: 10, max: 90, step: 5, default: 30 },
|
||||
{ id: 'count', label: 'Colors', type: 'slider', min: 3, max: 9, step: 1, default: 5 }
|
||||
],
|
||||
generate: (p) =>
|
||||
renderSwatches(
|
||||
analogousSet(str(p, 'baseColor'), num(p, 'spread'), num(p, 'count')),
|
||||
num(p, 'width'),
|
||||
str(p, 'layout') as 'strip' | 'grid'
|
||||
)
|
||||
},
|
||||
{
|
||||
id: 'monochromatic-png',
|
||||
title: 'Monochromatic Palette PNG',
|
||||
description: 'Tones of a single hue: lightness varies within the chosen range, hue and saturation stay fixed.',
|
||||
category: 'generate',
|
||||
sourceMode: 'none',
|
||||
params: [
|
||||
...paletteParams('#0ea5e9'),
|
||||
{ id: 'count', label: 'Colors', type: 'slider', min: 2, max: 9, step: 1, default: 5 },
|
||||
{ id: 'range', label: 'Lightness range, %', type: 'slider', min: 10, max: 90, step: 5, default: 40 }
|
||||
],
|
||||
generate: (p) =>
|
||||
renderSwatches(
|
||||
monochromaticSet(str(p, 'baseColor'), num(p, 'count'), num(p, 'range')),
|
||||
num(p, 'width'),
|
||||
str(p, 'layout') as 'strip' | 'grid'
|
||||
)
|
||||
},
|
||||
{
|
||||
id: 'shades-png',
|
||||
title: 'Shade Ramp PNG',
|
||||
description: 'A ramp of the base color getting darker step by step.',
|
||||
category: 'generate',
|
||||
sourceMode: 'none',
|
||||
params: [
|
||||
...paletteParams('#f59e0b'),
|
||||
{ id: 'count', label: 'Colors', type: 'slider', min: 2, max: 9, step: 1, default: 5 },
|
||||
{ id: 'depth', label: 'Darkening depth, %', type: 'slider', min: 10, max: 90, step: 5, default: 50 }
|
||||
],
|
||||
generate: (p) =>
|
||||
renderSwatches(
|
||||
shadeSet(str(p, 'baseColor'), num(p, 'count'), num(p, 'depth')),
|
||||
num(p, 'width'),
|
||||
str(p, 'layout') as 'strip' | 'grid'
|
||||
)
|
||||
},
|
||||
{
|
||||
id: 'mix-colors-png',
|
||||
title: 'Mix Colors PNG',
|
||||
description:
|
||||
'Averages several hex colors into one swatch. Enter comma-separated #hex values; invalid tokens are skipped.',
|
||||
category: 'generate',
|
||||
sourceMode: 'none',
|
||||
params: [
|
||||
{
|
||||
id: 'colors',
|
||||
label: 'Colors (comma-separated hex)',
|
||||
type: 'text',
|
||||
default: '#ff0000,#00ff00,#0000ff'
|
||||
},
|
||||
{ id: 'width', label: 'Width', type: 'slider', min: 128, max: 1024, step: 16, default: 512 }
|
||||
],
|
||||
generate: (p) => renderSwatches([mixColors(parseHexList(str(p, 'colors')))], num(p, 'width'), 'strip')
|
||||
},
|
||||
{
|
||||
id: 'blend-two-png',
|
||||
title: 'Blend Two Colors PNG',
|
||||
description: 'A continuous horizontal gradient between two colors.',
|
||||
category: 'generate',
|
||||
sourceMode: 'none',
|
||||
params: [
|
||||
{ id: 'colorA', label: 'Color A', type: 'color', default: '#000000' },
|
||||
{ id: 'colorB', label: 'Color B', type: 'color', default: '#ffffff' },
|
||||
{ id: 'width', label: 'Width', type: 'slider', min: 128, max: 1024, step: 16, default: 512 }
|
||||
],
|
||||
generate: (p) => renderBlend(str(p, 'colorA'), str(p, 'colorB'), num(p, 'width'))
|
||||
},
|
||||
{
|
||||
id: 'step-colors-png',
|
||||
title: 'Color Steps PNG',
|
||||
description: 'A discrete set of evenly spaced steps between two colors.',
|
||||
category: 'generate',
|
||||
sourceMode: 'none',
|
||||
params: [
|
||||
{ id: 'colorA', label: 'Color A', type: 'color', default: '#000000' },
|
||||
{ id: 'colorB', label: 'Color B', type: 'color', default: '#ffffff' },
|
||||
{ id: 'steps', label: 'Steps', type: 'slider', min: 2, max: 12, step: 1, default: 6 },
|
||||
...paletteParams('#808080').filter((q) => q.id !== 'baseColor')
|
||||
],
|
||||
generate: (p) =>
|
||||
renderSwatches(
|
||||
stepColors(str(p, 'colorA'), str(p, 'colorB'), num(p, 'steps')),
|
||||
num(p, 'width'),
|
||||
str(p, 'layout') as 'strip' | 'grid'
|
||||
)
|
||||
},
|
||||
{
|
||||
id: 'sort-colors-png',
|
||||
title: 'Sort Colors PNG',
|
||||
description:
|
||||
'Renders your hex list as swatches sorted by hue, brightness or saturation. Invalid tokens are skipped.',
|
||||
category: 'generate',
|
||||
sourceMode: 'none',
|
||||
params: [
|
||||
{
|
||||
id: 'colors',
|
||||
label: 'Colors (comma-separated hex)',
|
||||
type: 'text',
|
||||
default: '#ff0000,#ff8800,#ffff00,#00cc44,#0066ff,#8800ff'
|
||||
},
|
||||
{
|
||||
id: 'order',
|
||||
label: 'Sort by',
|
||||
type: 'select',
|
||||
default: 'hue',
|
||||
options: [
|
||||
{ value: 'hue', label: 'Hue' },
|
||||
{ value: 'luma', label: 'Brightness' },
|
||||
{ value: 'sat', label: 'Saturation' }
|
||||
]
|
||||
},
|
||||
...paletteParams('#ffffff').filter((q) => q.id !== 'baseColor')
|
||||
],
|
||||
generate: (p) =>
|
||||
renderSwatches(
|
||||
sortPalette(parseHexList(str(p, 'colors')), str(p, 'order') as SortKey),
|
||||
num(p, 'width'),
|
||||
str(p, 'layout') as 'strip' | 'grid'
|
||||
)
|
||||
},
|
||||
{
|
||||
id: 'add-text-png',
|
||||
title: 'Add text to PNG',
|
||||
|
||||
@@ -101,6 +101,17 @@ export const TOOL_ICONS: Record<string, typeof AppWindow> = {
|
||||
'date-stamp-png': CalendarDays,
|
||||
'watermark-tile-png': Stamp,
|
||||
'watermark-image-png': FileImage,
|
||||
'color-wheel-png': Rainbow,
|
||||
'complementary-png': Contrast,
|
||||
'triadic-png': Hash,
|
||||
'tetradic-png': Grid3x3,
|
||||
'analogous-png': Blend,
|
||||
'monochromatic-png': Droplets,
|
||||
'shades-png': Sun,
|
||||
'mix-colors-png': PaintBucket,
|
||||
'blend-two-png': Layers,
|
||||
'step-colors-png': Scaling,
|
||||
'sort-colors-png': SearchCheck,
|
||||
'find-contour-png': Scan,
|
||||
'make-thicker-png': ZoomIn,
|
||||
'make-thinner-png': ZoomOut,
|
||||
|
||||
Reference in New Issue
Block a user