feat: add text and generation tools
This commit is contained in:
@@ -42,7 +42,7 @@
|
|||||||
Состав: pixelate, color-blocks, randomize-pixels (seed), add-noise, feather-edges, clean-edges, silhouette.
|
Состав: pixelate, color-blocks, randomize-pixels (seed), add-noise, feather-edges, clean-edges, silhouette.
|
||||||
Shadow/glow — сюда же, если потянет этап: оба = размытая альфа + смещение + цвет (ядро blur уже есть).
|
Shadow/glow — сюда же, если потянет этап: оба = размытая альфа + смещение + цвет (ядро blur уже есть).
|
||||||
|
|
||||||
### W7. Конвертеры и генераторы — ~10 инструментов, M
|
### W7. Конвертеры и генераторы — ВЫПОЛНЕНА (11 инструментов)
|
||||||
|
|
||||||
PNG↔bytes, PNG↔rgb-values (текстовые результаты), verify-is-png (анализ сигнатуры),
|
PNG↔bytes, PNG↔rgb-values (текстовые результаты), verify-is-png (анализ сигнатуры),
|
||||||
text-to-png (domText без входной картинки), emoji-to-png, placeholder-png,
|
text-to-png (domText без входной картинки), emoji-to-png, placeholder-png,
|
||||||
|
|||||||
+13
-13
@@ -21,6 +21,10 @@
|
|||||||
- png-to-base64 / base64-to-png — строка
|
- png-to-base64 / base64-to-png — строка
|
||||||
- png-to-data-uri / data-uri-to-png — строка
|
- png-to-data-uri / data-uri-to-png — строка
|
||||||
- png-to-hex — rrggbbaa по строкам / hex-to-png — tokens + width
|
- png-to-hex — rrggbbaa по строкам / hex-to-png — tokens + width
|
||||||
|
- png-to-bytes — десятичные RGBA-байты по строкам / bytes-to-png — tokens + width
|
||||||
|
- png-to-rgb-values — rgba(r,g,b,a) по пикселям / rgb-values-to-png — числа + width
|
||||||
|
- svg-to-png — width результата
|
||||||
|
- verify-is-png — текстовый источник (base64/data-uri), вердикт по сигнатуре
|
||||||
|
|
||||||
### Прозрачность
|
### Прозрачность
|
||||||
|
|
||||||
@@ -37,6 +41,7 @@
|
|||||||
- harden-alpha-png — threshold
|
- harden-alpha-png — threshold
|
||||||
- feather-edges-png — radius (размытие только альфы)
|
- feather-edges-png — radius (размытие только альфы)
|
||||||
- clean-edges-png — radius (defringe: RGB от ближайшего непрозрачного)
|
- clean-edges-png — radius (defringe: RGB от ближайшего непрозрачного)
|
||||||
|
- watermark-image-png — вторая картинка-знак (загружается на странице), scale, opacity, position, margin
|
||||||
- despeckle-alpha-png / close-holes-png — radius
|
- despeckle-alpha-png / close-holes-png — radius
|
||||||
- center-by-alpha-png — без параметров
|
- center-by-alpha-png — без параметров
|
||||||
- round-corners-png — radius
|
- round-corners-png — radius
|
||||||
@@ -118,13 +123,18 @@
|
|||||||
- single-color-png — width, height, color
|
- single-color-png — width, height, color
|
||||||
- random-noise-png — width, height, seed
|
- random-noise-png — width, height, seed
|
||||||
- linear-gradient-png — width, height, fromColor, toColor, direction
|
- linear-gradient-png — width, height, fromColor, toColor, direction
|
||||||
|
- text-to-png — text, fontSize, font, bold, color, transparentBg, backgroundColor, padding
|
||||||
|
- emoji-to-png — emoji, size
|
||||||
|
- placeholder-png — width, height, backgroundColor, color, showText
|
||||||
|
- color-spectrum-png — width, height, direction, saturation, lightness
|
||||||
|
- random-colors-png — width, height, blockSize, seed
|
||||||
|
- draw-grid-png — width, height, cols, rows, lineWidth, color, transparentBg
|
||||||
|
|
||||||
### Текст
|
### Текст
|
||||||
|
|
||||||
- add-text-png — text, fontSize, color, font, bold, position (3×3), margin, plate, plateColor, plateOpacity
|
- add-text-png — text, fontSize, color, font, bold, position (3×3), margin, plate, plateColor, plateOpacity
|
||||||
- date-stamp-png — format, fontSize, color, font, bold, position, margin, plate, plateColor, plateOpacity
|
- date-stamp-png — format, fontSize, color, font, bold, position, margin, plate, plateColor, plateOpacity
|
||||||
- watermark-tile-png — text, fontSize, color, opacity, angle, stepX, stepY, font, bold
|
- watermark-tile-png — text, fontSize, color, opacity, angle, stepX, stepY, font, bold
|
||||||
- watermark-image-png — вторая картинка-знак (загружается на странице), scale, opacity, position, margin
|
|
||||||
|
|
||||||
### Палитры и цветовые утилиты
|
### Палитры и цветовые утилиты
|
||||||
|
|
||||||
@@ -170,21 +180,12 @@
|
|||||||
- change-quality — честная семантика для lossless (см. план-гапы §риск)
|
- change-quality — честная семантика для lossless (см. план-гапы §риск)
|
||||||
- low-quality-png — частично покрыт jpeg-artifacts; остаток = сильный quantize
|
- low-quality-png — частично покрыт jpeg-artifacts; остаток = сильный quantize
|
||||||
|
|
||||||
### Генераторы
|
### Генераторы — остаток
|
||||||
|
|
||||||
- text-to-png — text, font, size, textColor, bgColor, padding (движок domText уже есть)
|
|
||||||
- emoji-to-png — emoji, size, шрифт эмодзи
|
|
||||||
- placeholder-png — w, h, text?, bg, fg
|
|
||||||
- color-spectrum — w, h, пространство (hsv-радуга)
|
|
||||||
- multi-color-gradient — список стопов (нужен новый тип параметра → MEDIUM-UI)
|
- multi-color-gradient — список стопов (нужен новый тип параметра → MEDIUM-UI)
|
||||||
- colorful-random — блоки случайных цветов, размер блока, seed
|
|
||||||
- draw-grid — cols, rows, lineWidth, color, прозрачный фон
|
|
||||||
|
|
||||||
### Конвертеры
|
### Конвертеры — остаток
|
||||||
|
|
||||||
- png-to-bytes / bytes-to-png — формат строки (dec/hex), порядок каналов
|
|
||||||
- png-to-rgb-values / rgb-values-to-png — аналогично
|
|
||||||
- verify-is-png — анализ сигнатуры файла (текстовый вердикт)
|
|
||||||
- png-to-gif — MEDIUM (однокадровый GIF-энкодер руками)
|
- png-to-gif — MEDIUM (однокадровый GIF-энкодер руками)
|
||||||
- gif-to-frames — MEDIUM (мультифайловый вывод → идея)
|
- gif-to-frames — MEDIUM (мультифайловый вывод → идея)
|
||||||
- change-bit-depth — MEDIUM (пересборка PNG)
|
- change-bit-depth — MEDIUM (пересборка PNG)
|
||||||
@@ -192,7 +193,6 @@
|
|||||||
### Прочее единичное
|
### Прочее единичное
|
||||||
|
|
||||||
- pick-a-color — пипетка уже есть в превью; отдельная страница не планируется (покрыто)
|
- pick-a-color — пипетка уже есть в превью; отдельная страница не планируется (покрыто)
|
||||||
- watermark-image — в плане волны 4 (этап D)
|
|
||||||
- preview/test на цветных фонах — фича превью, не инструмент (решить позже)
|
- preview/test на цветных фонах — фича превью, не инструмент (решить позже)
|
||||||
- extract-barcode — HARD, вне планов
|
- extract-barcode — HARD, вне планов
|
||||||
|
|
||||||
|
|||||||
@@ -153,9 +153,22 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function handleTextSubmit(text: string) {
|
async function handleTextSubmit(text: string) {
|
||||||
if (!isTextSource || !tool.runFromText) return;
|
if (!isTextSource) return;
|
||||||
errorText = '';
|
errorText = '';
|
||||||
status = 'processing';
|
status = 'processing';
|
||||||
|
if (tool.textToText) {
|
||||||
|
try {
|
||||||
|
result = null;
|
||||||
|
previewResult = null;
|
||||||
|
info = null;
|
||||||
|
textResult = await tool.textToText(text);
|
||||||
|
status = 'loaded';
|
||||||
|
} catch (e) {
|
||||||
|
showError(e);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!tool.runFromText) return;
|
||||||
try {
|
try {
|
||||||
source = await tool.runFromText(text, defaultParams(tool));
|
source = await tool.runFromText(text, defaultParams(tool));
|
||||||
values = defaultParams(tool);
|
values = defaultParams(tool);
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
|
|
||||||
let { onSelect, chainableOnly = false }: Props = $props();
|
let { onSelect, chainableOnly = false }: Props = $props();
|
||||||
|
|
||||||
const candidates = chainableOnly ? TOOLS.filter(isChainable) : TOOLS;
|
const candidates = $derived(chainableOnly ? TOOLS.filter(isChainable) : TOOLS);
|
||||||
|
|
||||||
let query = $state('');
|
let query = $state('');
|
||||||
let activeIndex = $state(0);
|
let activeIndex = $state(0);
|
||||||
|
|||||||
@@ -92,6 +92,52 @@ export interface TileTextOptions extends Omit<TextBlockOptions, 'position' | 'ma
|
|||||||
angleDeg: number;
|
angleDeg: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface TextToImageOptions {
|
||||||
|
text: string;
|
||||||
|
fontSize: number;
|
||||||
|
font: TextFont;
|
||||||
|
bold: boolean;
|
||||||
|
color: string;
|
||||||
|
backgroundColor: string;
|
||||||
|
transparentBg: boolean;
|
||||||
|
padding: number;
|
||||||
|
maxTextWidth?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Картинка из текста: холст подгоняется под размер надписи с паддингом. */
|
||||||
|
export function renderTextToImage(o: TextToImageOptions): PixelImage {
|
||||||
|
const measure = ctx2d(8, 8).ctx;
|
||||||
|
measure.font = fontString(o.fontSize, o.font, o.bold);
|
||||||
|
const maxWidth = o.maxTextWidth ?? 4000;
|
||||||
|
const lines = [o.text];
|
||||||
|
const textW = Math.min(maxWidth, Math.max(measure.measureText(o.text).width, 1));
|
||||||
|
const lineH = o.fontSize * 1.25;
|
||||||
|
|
||||||
|
const w = Math.max(1, Math.ceil(textW + o.padding * 2));
|
||||||
|
const h = Math.max(1, Math.ceil(lineH + o.padding * 2));
|
||||||
|
const { canvas, ctx } = ctx2d(w, h);
|
||||||
|
|
||||||
|
if (!o.transparentBg) {
|
||||||
|
ctx.fillStyle = o.backgroundColor;
|
||||||
|
ctx.fillRect(0, 0, w, h);
|
||||||
|
}
|
||||||
|
ctx.font = fontString(o.fontSize, o.font, o.bold);
|
||||||
|
ctx.fillStyle = o.color;
|
||||||
|
ctx.textBaseline = 'top';
|
||||||
|
lines.forEach((line) => ctx.fillText(line, o.padding, o.padding));
|
||||||
|
return toPixelImage(canvas);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Эмодзи/символ как PNG: рисуется платформенным шрифтом по центру. */
|
||||||
|
export function renderEmoji(symbol: string, size: number): PixelImage {
|
||||||
|
const { canvas, ctx } = ctx2d(size, size);
|
||||||
|
ctx.font = `${Math.round(size * 0.72)}px "Apple Color Emoji","Segoe UI Emoji","Noto Color Emoji",sans-serif`;
|
||||||
|
ctx.textAlign = 'center';
|
||||||
|
ctx.textBaseline = 'middle';
|
||||||
|
ctx.fillText(symbol, size / 2, size / 2 + size * 0.04);
|
||||||
|
return toPixelImage(canvas);
|
||||||
|
}
|
||||||
|
|
||||||
export interface ImageWatermarkOptions {
|
export interface ImageWatermarkOptions {
|
||||||
mark: PixelImage;
|
mark: PixelImage;
|
||||||
scalePercent: number;
|
scalePercent: number;
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { colorSpectrum, drawGrid, randomColorBlocks } from './gen-tools';
|
||||||
|
|
||||||
|
describe('colorSpectrum', () => {
|
||||||
|
it('горизонтальный: левый край красный (hue 0)', () => {
|
||||||
|
const img = colorSpectrum(100, 10, 'horizontal', 100, 50);
|
||||||
|
expect(img.data[0]).toBeGreaterThan(200);
|
||||||
|
expect(img.data[1]).toBeLessThan(60);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('вертикальный: зелёный максимум около hue 120°', () => {
|
||||||
|
const img = colorSpectrum(10, 100, 'vertical', 100, 50);
|
||||||
|
const rowHue120 = Math.round((120 / 360) * 99);
|
||||||
|
const g = img.data[(rowHue120 * 10 + 5) * 4 + 1];
|
||||||
|
expect(g).toBeGreaterThan(200);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('randomColorBlocks', () => {
|
||||||
|
it('детерминирован по seed и блоки однотонные', () => {
|
||||||
|
const a = randomColorBlocks(64, 64, 16, 7);
|
||||||
|
const b = randomColorBlocks(64, 64, 16, 7);
|
||||||
|
expect([...a.data]).toEqual([...b.data]);
|
||||||
|
const c = randomColorBlocks(64, 64, 16, 8);
|
||||||
|
expect([...a.data]).not.toEqual([...c.data]);
|
||||||
|
expect(a.data[3]).toBe(255);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('drawGrid', () => {
|
||||||
|
it('линии на пересечениях непрозрачны, фон прозрачен', () => {
|
||||||
|
const out = drawGrid(100, 100, 4, 4, 2, '#000000', true);
|
||||||
|
expect(out.data[0]).toBe(0);
|
||||||
|
expect(out.data[3]).toBe(255); // (0,0) на линии
|
||||||
|
const mid = ((53 * 100) + 53) * 4;
|
||||||
|
expect(out.data[mid + 3]).toBe(0); // между линиями прозрачн
|
||||||
|
});
|
||||||
|
|
||||||
|
it('белый непрозрачный фон при transparentBg=false', () => {
|
||||||
|
const out = drawGrid(20, 20, 2, 2, 1, '#000000', false);
|
||||||
|
const mid = ((5 * 20) + 5) * 4;
|
||||||
|
expect(out.data[mid]).toBe(255);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
import { createPixelImage, type PixelImage } from './types';
|
||||||
|
import { hslToRgb } from './palette';
|
||||||
|
import { mulberry32 } from './pixel-fx';
|
||||||
|
|
||||||
|
/** Радужный спектр: оттенок 0..360 вдоль выбранной оси. */
|
||||||
|
export function colorSpectrum(
|
||||||
|
width: number,
|
||||||
|
height: number,
|
||||||
|
direction: 'horizontal' | 'vertical',
|
||||||
|
saturationPercent: number,
|
||||||
|
lightnessPercent: number
|
||||||
|
): PixelImage {
|
||||||
|
const out = createPixelImage(width, height);
|
||||||
|
for (let y = 0; y < height; y++) {
|
||||||
|
for (let x = 0; x < width; x++) {
|
||||||
|
const t = direction === 'vertical' ? y / Math.max(1, height - 1) : x / Math.max(1, width - 1);
|
||||||
|
const { r, g, b } = hslToRgb({
|
||||||
|
h: t * 360,
|
||||||
|
s: saturationPercent / 100,
|
||||||
|
l: lightnessPercent / 100
|
||||||
|
});
|
||||||
|
const di = (y * width + x) * 4;
|
||||||
|
out.data[di] = r;
|
||||||
|
out.data[di + 1] = g;
|
||||||
|
out.data[di + 2] = b;
|
||||||
|
out.data[di + 3] = 255;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Случайные яркие блоки: детерминировано по seed. */
|
||||||
|
export function randomColorBlocks(
|
||||||
|
width: number,
|
||||||
|
height: number,
|
||||||
|
blockSize: number,
|
||||||
|
seed: number
|
||||||
|
): PixelImage {
|
||||||
|
const bs = Math.max(1, Math.round(blockSize));
|
||||||
|
const out = createPixelImage(width, height);
|
||||||
|
const rng = mulberry32(seed);
|
||||||
|
for (let by = 0; by < height; by += bs) {
|
||||||
|
for (let bx = 0; bx < width; bx += bs) {
|
||||||
|
const { r, g, b } = hslToRgb({
|
||||||
|
h: rng() * 360,
|
||||||
|
s: 0.65 + rng() * 0.35,
|
||||||
|
l: 0.45 + rng() * 0.25
|
||||||
|
});
|
||||||
|
const yMax = Math.min(by + bs, height);
|
||||||
|
const xMax = Math.min(bx + bs, width);
|
||||||
|
for (let y = by; y < yMax; y++) {
|
||||||
|
for (let x = bx; x < xMax; x++) {
|
||||||
|
const di = (y * width + x) * 4;
|
||||||
|
out.data[di] = r;
|
||||||
|
out.data[di + 1] = g;
|
||||||
|
out.data[di + 2] = b;
|
||||||
|
out.data[di + 3] = 255;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function lineMask(length: number, divisions: number, lineWidth: number): Uint8Array {
|
||||||
|
const mask = new Uint8Array(length);
|
||||||
|
const lw = Math.max(1, Math.round(lineWidth));
|
||||||
|
for (let i = 0; i <= divisions; i++) {
|
||||||
|
const start = Math.round((i * length) / divisions);
|
||||||
|
for (let x = start; x < Math.min(start + lw, length); x++) mask[x] = 1;
|
||||||
|
}
|
||||||
|
return mask;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Сетка линий на прозрачном или белом фоне. */
|
||||||
|
export function drawGrid(
|
||||||
|
width: number,
|
||||||
|
height: number,
|
||||||
|
cols: number,
|
||||||
|
rows: number,
|
||||||
|
lineWidth: number,
|
||||||
|
colorHex: string,
|
||||||
|
transparentBg: boolean
|
||||||
|
): PixelImage {
|
||||||
|
const out = createPixelImage(width, height);
|
||||||
|
if (!transparentBg) out.data.fill(255);
|
||||||
|
|
||||||
|
const m = /^#([0-9a-f]{6})$/i.exec(colorHex.trim());
|
||||||
|
const r = m ? parseInt(m[1].slice(0, 2), 16) : 0;
|
||||||
|
const g = m ? parseInt(m[1].slice(2, 4), 16) : 0;
|
||||||
|
const b = m ? parseInt(m[1].slice(4, 6), 16) : 0;
|
||||||
|
|
||||||
|
const colMask = lineMask(width, Math.max(1, cols), lineWidth);
|
||||||
|
const rowMask = lineMask(height, Math.max(1, rows), lineWidth);
|
||||||
|
for (let y = 0; y < height; y++) {
|
||||||
|
for (let x = 0; x < width; x++) {
|
||||||
|
if (!colMask[x] && !rowMask[y]) continue;
|
||||||
|
const di = (y * width + x) * 4;
|
||||||
|
out.data[di] = r;
|
||||||
|
out.data[di + 1] = g;
|
||||||
|
out.data[di + 2] = b;
|
||||||
|
out.data[di + 3] = 255;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import {
|
||||||
|
base64ToBytes,
|
||||||
|
bytesToImage,
|
||||||
|
imageToByteRows,
|
||||||
|
imageToRgbValues,
|
||||||
|
looksLikePng,
|
||||||
|
rgbValuesToImage,
|
||||||
|
stripDataUri
|
||||||
|
} from './textio';
|
||||||
|
import { makeImage } from './test-helpers';
|
||||||
|
|
||||||
|
const img = makeImage(2, 1, [
|
||||||
|
[255, 0, 0, 255],
|
||||||
|
[0, 255, 0, 128]
|
||||||
|
]);
|
||||||
|
|
||||||
|
describe('bytes', () => {
|
||||||
|
it('round-trip rows → image → rows', () => {
|
||||||
|
const rows = imageToByteRows(img);
|
||||||
|
expect(rows).toBe('255 0 0 255 0 255 0 128');
|
||||||
|
const back = bytesToImage(rows, 2);
|
||||||
|
expect([...back.data]).toEqual([...img.data]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('некратное четырём число байт — ошибка', () => {
|
||||||
|
expect(() => bytesToImage('1 2 3', 1)).toThrow(/errors\.bytesCount/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('значение вне 0..255 — ошибка диапазона', () => {
|
||||||
|
expect(() => bytesToImage('10 20 30 256', 1)).toThrow(/errors\.byteRange/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('rgb values', () => {
|
||||||
|
it('round-trip rgba-строк', () => {
|
||||||
|
const rows = imageToRgbValues(img);
|
||||||
|
expect(rows.split('\n')[0]).toBe('rgba(255, 0, 0, 255) rgba(0, 255, 0, 128)');
|
||||||
|
const back = rgbValuesToImage(rows.replace(/rgba\(|\)/g, ''), 2);
|
||||||
|
expect([...back.data]).toEqual([...img.data]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('png signature / data-uri', () => {
|
||||||
|
it('looksLikePng: настоящая сигнатура и обрывок', () => {
|
||||||
|
const good = new Uint8Array([137, 80, 78, 71, 13, 10, 26, 10, 1]);
|
||||||
|
const bad = new Uint8Array([137, 80, 78]);
|
||||||
|
expect(looksLikePng(good)).toBe(true);
|
||||||
|
expect(looksLikePng(bad)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stripDataUri снимает префикс и оставляет чистый base64', () => {
|
||||||
|
expect(stripDataUri('data:image/png;base64,iVBORw==')).toBe('iVBORw==');
|
||||||
|
expect(stripDataUri(' iVBORw==')).toBe('iVBORw==');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('base64ToBytes', () => {
|
||||||
|
it('декодирует известную строку', () => {
|
||||||
|
const bytes = base64ToBytes('AAECAwQ=');
|
||||||
|
expect([...bytes]).toEqual([0, 1, 2, 3, 4]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import { ToolError } from './errors';
|
||||||
|
import type { PixelImage } from './types';
|
||||||
|
import { createPixelImage } from './types';
|
||||||
|
|
||||||
|
const PNG_SIGNATURE = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
|
||||||
|
|
||||||
|
/** Строки вида «r g b a» по одной строке на ряд пикселей. */
|
||||||
|
export function imageToByteRows(img: PixelImage): string {
|
||||||
|
const rows: string[] = [];
|
||||||
|
for (let y = 0; y < img.height; y++) {
|
||||||
|
const parts: string[] = [];
|
||||||
|
for (let x = 0; x < img.width; x++) {
|
||||||
|
const i = (y * img.width + x) * 4;
|
||||||
|
parts.push(`${img.data[i]} ${img.data[i + 1]} ${img.data[i + 2]} ${img.data[i + 3]}`);
|
||||||
|
}
|
||||||
|
rows.push(parts.join(' '));
|
||||||
|
}
|
||||||
|
return rows.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function bytesToImage(text: string, width: number): PixelImage {
|
||||||
|
const nums = (text.match(/-?\d+/g) ?? []).map(Number);
|
||||||
|
if (nums.length % 4 !== 0) {
|
||||||
|
throw new ToolError('errors.bytesCount', { count: nums.length });
|
||||||
|
}
|
||||||
|
if (nums.some((n) => !Number.isInteger(n) || n < 0 || n > 255)) {
|
||||||
|
throw new ToolError('errors.byteRange');
|
||||||
|
}
|
||||||
|
const w = Math.trunc(width);
|
||||||
|
if (!Number.isInteger(w) || w < 1) throw new ToolError('errors.widthInt');
|
||||||
|
if (nums.length / 4 % w !== 0) {
|
||||||
|
throw new ToolError('errors.pixelCountMismatch', { count: nums.length / 4, width: w });
|
||||||
|
}
|
||||||
|
const out = createPixelImage(w, nums.length / 4 / w);
|
||||||
|
out.data.set(nums);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Строки вида «rgba(r, g, b, a)», по одной на пиксель, ряды через перевод строки. */
|
||||||
|
export function imageToRgbValues(img: PixelImage): string {
|
||||||
|
const rows: string[] = [];
|
||||||
|
for (let y = 0; y < img.height; y++) {
|
||||||
|
const parts: string[] = [];
|
||||||
|
for (let x = 0; x < img.width; x++) {
|
||||||
|
const i = (y * img.width + x) * 4;
|
||||||
|
parts.push(`rgba(${img.data[i]}, ${img.data[i + 1]}, ${img.data[i + 2]}, ${img.data[i + 3]})`);
|
||||||
|
}
|
||||||
|
rows.push(parts.join(' '));
|
||||||
|
}
|
||||||
|
return rows.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function rgbValuesToImage(text: string, width: number): PixelImage {
|
||||||
|
const nums = (text.match(/-?\d+(?:\.\d+)?/g) ?? []).map(Number);
|
||||||
|
if (nums.length % 4 !== 0) {
|
||||||
|
throw new ToolError('errors.bytesCount', { count: nums.length });
|
||||||
|
}
|
||||||
|
if (nums.some((n) => !Number.isInteger(n) || n < 0 || n > 255)) {
|
||||||
|
throw new ToolError('errors.byteRange');
|
||||||
|
}
|
||||||
|
const w = Math.trunc(width);
|
||||||
|
if (!Number.isInteger(w) || w < 1) throw new ToolError('errors.widthInt');
|
||||||
|
const pxCount = nums.length / 4;
|
||||||
|
if (pxCount % w !== 0) {
|
||||||
|
throw new ToolError('errors.pixelCountMismatch', { count: pxCount, width: w });
|
||||||
|
}
|
||||||
|
const out = createPixelImage(w, pxCount / w);
|
||||||
|
out.data.set(nums);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Снимает префикс data-uri (data:image/...;base64,) при наличии. */
|
||||||
|
export function stripDataUri(text: string): string {
|
||||||
|
const m = /^\s*data:[^,\s]*base64,/i.exec(text);
|
||||||
|
return m ? text.slice(m[0].length) : text.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function base64ToBytes(text: string): Uint8Array {
|
||||||
|
const clean = text.replace(/\s+/g, '');
|
||||||
|
const binary = atob(clean);
|
||||||
|
const bytes = new Uint8Array(binary.length);
|
||||||
|
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
|
||||||
|
return bytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function looksLikePng(bytes: Uint8Array): boolean {
|
||||||
|
if (bytes.length < PNG_SIGNATURE.length) return false;
|
||||||
|
return PNG_SIGNATURE.every((v, i) => bytes[i] === v);
|
||||||
|
}
|
||||||
@@ -148,6 +148,12 @@ export const en: Dict = {
|
|||||||
sizePositive: 'Dimensions must be positive and finite'
|
sizePositive: 'Dimensions must be positive and finite'
|
||||||
},
|
},
|
||||||
tools: {
|
tools: {
|
||||||
|
'verify-is-png': {
|
||||||
|
results: {
|
||||||
|
verifyYes: 'Yes — this is a valid PNG signature.',
|
||||||
|
verifyNo: 'No — the signature does not match a PNG file.'
|
||||||
|
}
|
||||||
|
},
|
||||||
'png-is-grayscale': {
|
'png-is-grayscale': {
|
||||||
results: {
|
results: {
|
||||||
grayscaleYes: 'Yes — all pixels are shades of gray.',
|
grayscaleYes: 'Yes — all pixels are shades of gray.',
|
||||||
|
|||||||
@@ -759,6 +759,100 @@ export const ru: Dict = {
|
|||||||
'Заливает все видимые пиксели одним цветом, сохраняя их прозрачность — мгновенный силуэт.',
|
'Заливает все видимые пиксели одним цветом, сохраняя их прозрачность — мгновенный силуэт.',
|
||||||
params: { color: 'Цвет силуэта', threshold: 'Порог видимости, %' }
|
params: { color: 'Цвет силуэта', threshold: 'Порог видимости, %' }
|
||||||
},
|
},
|
||||||
|
'png-to-bytes': {
|
||||||
|
title: 'PNG в байты',
|
||||||
|
description: 'Выводит каждый пиксель четырьмя десятичными байтами (R G B A), по строке изображения на строку текста.'
|
||||||
|
},
|
||||||
|
'bytes-to-png': {
|
||||||
|
title: 'Байты в PNG',
|
||||||
|
description:
|
||||||
|
'Собирает изображение из десятичных RGBA-байтов (разделители любые). Укажите ширину — высота рассчитается сама.',
|
||||||
|
params: { width: 'Ширина изображения' }
|
||||||
|
},
|
||||||
|
'png-to-rgb-values': {
|
||||||
|
title: 'PNG в RGB-значения',
|
||||||
|
description: 'Выводит каждый пиксель как rgba(r, g, b, a), ряды через перевод строки.'
|
||||||
|
},
|
||||||
|
'rgb-values-to-png': {
|
||||||
|
title: 'RGB-значения в PNG',
|
||||||
|
description:
|
||||||
|
'Собирает изображение из чисел rgba(r, g, b, a). Укажите ширину — высота рассчитается сама.',
|
||||||
|
params: { width: 'Ширина изображения' }
|
||||||
|
},
|
||||||
|
'verify-is-png': {
|
||||||
|
title: 'Проверить: это PNG?',
|
||||||
|
description:
|
||||||
|
'Проверяет сигнатуру вставленного base64 / data-uri и сообщает, настоящий ли это PNG.',
|
||||||
|
results: {
|
||||||
|
verifyYes: 'Да — сигнатура настоящего PNG.',
|
||||||
|
verifyNo: 'Нет — сигнатура не совпадает с PNG-файлом.'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
'text-to-png': {
|
||||||
|
title: 'Текст в PNG',
|
||||||
|
description: 'Создаёт PNG из текста: холст подгоняется под размер надписи с полями.',
|
||||||
|
params: {
|
||||||
|
text: 'Текст',
|
||||||
|
fontSize: 'Размер шрифта, px',
|
||||||
|
font: 'Шрифт',
|
||||||
|
bold: 'Жирный',
|
||||||
|
color: 'Цвет текста',
|
||||||
|
transparentBg: 'Прозрачный фон',
|
||||||
|
backgroundColor: 'Цвет фона',
|
||||||
|
padding: 'Отступ, px'
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
font: { sans: 'Без засечек', serif: 'С засечками', mono: 'Моноширинный' }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
'emoji-to-png': {
|
||||||
|
title: 'Эмодзи в PNG',
|
||||||
|
description: 'Рисует эмодзи или любой Unicode-символ как прозрачный PNG выбранного размера.',
|
||||||
|
params: { emoji: 'Эмодзи / символ', size: 'Размер' }
|
||||||
|
},
|
||||||
|
'placeholder-png': {
|
||||||
|
title: 'Заглушка PNG',
|
||||||
|
description: 'Генерирует прямоугольник-заглушку с напечатанными размерами в центре.',
|
||||||
|
params: {
|
||||||
|
width: 'Ширина',
|
||||||
|
height: 'Высота',
|
||||||
|
backgroundColor: 'Фон',
|
||||||
|
color: 'Цвет текста',
|
||||||
|
showText: 'Печатать размеры'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
'color-spectrum-png': {
|
||||||
|
title: 'Спектр цветов PNG',
|
||||||
|
description:
|
||||||
|
'Полный радужный переход 0–360° вдоль выбранной оси с настройкой насыщенности и светлоты.',
|
||||||
|
params: {
|
||||||
|
width: 'Ширина',
|
||||||
|
height: 'Высота',
|
||||||
|
direction: 'Направление',
|
||||||
|
saturation: 'Насыщенность, %',
|
||||||
|
lightness: 'Светлота, %'
|
||||||
|
},
|
||||||
|
options: { direction: { horizontal: 'По горизонтали', vertical: 'По вертикали' } }
|
||||||
|
},
|
||||||
|
'random-colors-png': {
|
||||||
|
title: 'Случайные цветные блоки PNG',
|
||||||
|
description: 'Заполняет холст случайными яркими блоками. Детерминировано по seed.',
|
||||||
|
params: { width: 'Ширина', height: 'Высота', blockSize: 'Размер блока, px', seed: 'Seed' }
|
||||||
|
},
|
||||||
|
'draw-grid-png': {
|
||||||
|
title: 'Нарисовать сетку PNG',
|
||||||
|
description:
|
||||||
|
'Рисует сетку с заданными столбцами, строками и толщиной линий на прозрачном или белом фоне.',
|
||||||
|
params: {
|
||||||
|
width: 'Ширина',
|
||||||
|
height: 'Высота',
|
||||||
|
cols: 'Столбцы',
|
||||||
|
rows: 'Строки',
|
||||||
|
lineWidth: 'Толщина линий, px',
|
||||||
|
color: 'Цвет линий',
|
||||||
|
transparentBg: 'Прозрачный фон'
|
||||||
|
}
|
||||||
|
},
|
||||||
'show-transparent-png': {
|
'show-transparent-png': {
|
||||||
title: 'Показать прозрачные области PNG',
|
title: 'Показать прозрачные области PNG',
|
||||||
description:
|
description:
|
||||||
|
|||||||
@@ -26,7 +26,9 @@ describe('реестр инструментов', () => {
|
|||||||
|
|
||||||
it.each(TOOLS.map((t) => [t.id, t] as const))('%s: исполнители определены', (_, tool) => {
|
it.each(TOOLS.map((t) => [t.id, t] as const))('%s: исполнители определены', (_, tool) => {
|
||||||
if (tool.resultType === 'text') {
|
if (tool.resultType === 'text') {
|
||||||
expect(typeof tool.toText).toBe('function');
|
expect(
|
||||||
|
typeof tool.toText === 'function' || typeof tool.textToText === 'function'
|
||||||
|
).toBe(true);
|
||||||
} else if (tool.sourceMode === 'none') {
|
} else if (tool.sourceMode === 'none') {
|
||||||
expect(typeof tool.generate).toBe('function');
|
expect(typeof tool.generate).toBe('function');
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
+238
-1
@@ -56,9 +56,21 @@ import {
|
|||||||
drawImageWatermark,
|
drawImageWatermark,
|
||||||
drawTextBlock,
|
drawTextBlock,
|
||||||
drawTextTile,
|
drawTextTile,
|
||||||
|
renderEmoji,
|
||||||
|
renderTextToImage,
|
||||||
type TextFont
|
type TextFont
|
||||||
} from './core/domText';
|
} from './core/domText';
|
||||||
import { getOverlay } from './tools/overlay-store.svelte';
|
import { getOverlay } from './tools/overlay-store.svelte';
|
||||||
|
import {
|
||||||
|
base64ToBytes,
|
||||||
|
bytesToImage,
|
||||||
|
imageToByteRows,
|
||||||
|
imageToRgbValues,
|
||||||
|
looksLikePng,
|
||||||
|
rgbValuesToImage,
|
||||||
|
stripDataUri
|
||||||
|
} from './core/textio';
|
||||||
|
import { colorSpectrum, drawGrid, randomColorBlocks } from './core/gen-tools';
|
||||||
import { formatStamp } from './core/datefmt';
|
import { formatStamp } from './core/datefmt';
|
||||||
import type { Position9 } from './core/textdraw';
|
import type { Position9 } from './core/textdraw';
|
||||||
import {
|
import {
|
||||||
@@ -161,6 +173,8 @@ export type ToolEntry = {
|
|||||||
generate?: (params: Record<string, unknown>) => Promise<PixelImage> | PixelImage;
|
generate?: (params: Record<string, unknown>) => Promise<PixelImage> | PixelImage;
|
||||||
toText?: (img: PixelImage, params: Record<string, unknown>) => Promise<string> | string;
|
toText?: (img: PixelImage, params: Record<string, unknown>) => Promise<string> | string;
|
||||||
runFromText?: (text: string, params: Record<string, unknown>) => Promise<PixelImage> | PixelImage;
|
runFromText?: (text: string, params: Record<string, unknown>) => Promise<PixelImage> | PixelImage;
|
||||||
|
/** Текстовый источник + текстовый результат без промежуточного изображения. */
|
||||||
|
textToText?: (text: string) => Promise<string> | string;
|
||||||
preview?: (
|
preview?: (
|
||||||
img: PixelImage,
|
img: PixelImage,
|
||||||
params: Record<string, unknown>
|
params: Record<string, unknown>
|
||||||
@@ -601,6 +615,53 @@ export const TOOLS: ToolEntry[] = [
|
|||||||
runFromText: (text, p) => hexToPixels(text, Math.trunc(Number(p['width']))),
|
runFromText: (text, p) => hexToPixels(text, Math.trunc(Number(p['width']))),
|
||||||
run: (img) => clonePixelImage(img)
|
run: (img) => clonePixelImage(img)
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'png-to-bytes',
|
||||||
|
title: 'PNG to Bytes',
|
||||||
|
description:
|
||||||
|
'Lists every pixel as four decimal bytes (R G B A), one image row per line.',
|
||||||
|
category: 'convert',
|
||||||
|
params: [],
|
||||||
|
resultType: 'text',
|
||||||
|
toText: (img) => imageToByteRows(img)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'bytes-to-png',
|
||||||
|
title: 'Bytes to PNG',
|
||||||
|
description:
|
||||||
|
'Assembles an image from decimal RGBA byte numbers (any separators). Set the width — height is computed automatically.',
|
||||||
|
category: 'convert',
|
||||||
|
sourceMode: 'text',
|
||||||
|
params: [
|
||||||
|
{ id: 'width', label: 'Image width', type: 'number', min: 1, max: 10000, step: 1, default: 32 }
|
||||||
|
],
|
||||||
|
runFromText: (text, p) => bytesToImage(text, Math.trunc(num(p as Record<string, unknown>, 'width'))),
|
||||||
|
run: (img) => clonePixelImage(img)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'png-to-rgb-values',
|
||||||
|
title: 'PNG to RGB Values',
|
||||||
|
description:
|
||||||
|
'Lists every pixel as rgba(r, g, b, a), one image row per line.',
|
||||||
|
category: 'convert',
|
||||||
|
params: [],
|
||||||
|
resultType: 'text',
|
||||||
|
toText: (img) => imageToRgbValues(img)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'rgb-values-to-png',
|
||||||
|
title: 'RGB Values to PNG',
|
||||||
|
description:
|
||||||
|
'Assembles an image from rgba(r, g, b, a) numbers. Set the width — height is computed automatically.',
|
||||||
|
category: 'convert',
|
||||||
|
sourceMode: 'text',
|
||||||
|
params: [
|
||||||
|
{ id: 'width', label: 'Image width', type: 'number', min: 1, max: 10000, step: 1, default: 32 }
|
||||||
|
],
|
||||||
|
runFromText: (text, p) =>
|
||||||
|
rgbValuesToImage(text, Math.trunc(num(p as Record<string, unknown>, 'width'))),
|
||||||
|
run: (img) => clonePixelImage(img)
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: 'resize-png',
|
id: 'resize-png',
|
||||||
title: 'Resize PNG',
|
title: 'Resize PNG',
|
||||||
@@ -1230,6 +1291,17 @@ export const TOOLS: ToolEntry[] = [
|
|||||||
],
|
],
|
||||||
run: (img, p) => extractByColor(img, str(p, 'color'), num(p, 'tolerance'))
|
run: (img, p) => extractByColor(img, str(p, 'color'), num(p, 'tolerance'))
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'verify-is-png',
|
||||||
|
title: 'Verify If Image Is a PNG',
|
||||||
|
description:
|
||||||
|
'Checks the signature of pasted base64 / data-uri content and reports whether it is a real PNG.',
|
||||||
|
category: 'analyze',
|
||||||
|
sourceMode: 'text',
|
||||||
|
params: [],
|
||||||
|
resultType: 'text',
|
||||||
|
textToText: (text) => (looksLikePng(base64ToBytes(stripDataUri(text))) ? 'verifyYes' : 'verifyNo')
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: 'png-info',
|
id: 'png-info',
|
||||||
title: 'PNG info',
|
title: 'PNG info',
|
||||||
@@ -1293,6 +1365,91 @@ export const TOOLS: ToolEntry[] = [
|
|||||||
],
|
],
|
||||||
generate: (p) => noiseImage(Math.trunc(num(p, 'width')), Math.trunc(num(p, 'height')), num(p, 'seed'))
|
generate: (p) => noiseImage(Math.trunc(num(p, 'width')), Math.trunc(num(p, 'height')), num(p, 'seed'))
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'text-to-png',
|
||||||
|
title: 'Text to PNG',
|
||||||
|
description:
|
||||||
|
'Creates a PNG image from text: the canvas is sized to fit the label plus padding.',
|
||||||
|
category: 'generate',
|
||||||
|
sourceMode: 'none',
|
||||||
|
domOnly: true,
|
||||||
|
params: [
|
||||||
|
{ id: 'text', label: 'Text', type: 'text', default: 'Hello!', placeholder: 'Your text' },
|
||||||
|
{ id: 'fontSize', label: 'Font size, px', type: 'slider', min: 8, max: 300, step: 1, default: 96 },
|
||||||
|
{
|
||||||
|
id: 'font',
|
||||||
|
label: 'Font',
|
||||||
|
type: 'select',
|
||||||
|
default: 'sans',
|
||||||
|
options: [
|
||||||
|
{ value: 'sans', label: 'Sans-serif' },
|
||||||
|
{ value: 'serif', label: 'Serif' },
|
||||||
|
{ value: 'mono', label: 'Monospace' }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{ id: 'bold', label: 'Bold', type: 'checkbox', default: true },
|
||||||
|
{ id: 'color', label: 'Text color', type: 'color', default: '#111318' },
|
||||||
|
{ id: 'transparentBg', label: 'Transparent background', type: 'checkbox', default: false },
|
||||||
|
{ id: 'backgroundColor', label: 'Background color', type: 'color', default: '#ffffff' },
|
||||||
|
{ id: 'padding', label: 'Padding, px', type: 'slider', min: 0, max: 200, step: 2, default: 24 }
|
||||||
|
],
|
||||||
|
generate: (p) =>
|
||||||
|
renderTextToImage({
|
||||||
|
text: str(p, 'text'),
|
||||||
|
fontSize: num(p, 'fontSize'),
|
||||||
|
font: str(p, 'font') as TextFont,
|
||||||
|
bold: bool(p, 'bold'),
|
||||||
|
color: str(p, 'color'),
|
||||||
|
backgroundColor: str(p, 'backgroundColor'),
|
||||||
|
transparentBg: bool(p, 'transparentBg'),
|
||||||
|
padding: num(p, 'padding')
|
||||||
|
})
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'emoji-to-png',
|
||||||
|
title: 'Emoji to PNG',
|
||||||
|
description:
|
||||||
|
'Renders an emoji or any Unicode symbol as a transparent PNG of the chosen size.',
|
||||||
|
category: 'generate',
|
||||||
|
sourceMode: 'none',
|
||||||
|
domOnly: true,
|
||||||
|
params: [
|
||||||
|
{ id: 'emoji', label: 'Emoji / symbol', type: 'text', default: '😀' },
|
||||||
|
{ id: 'size', label: 'Size', type: 'slider', min: 32, max: 1024, step: 16, default: 256 }
|
||||||
|
],
|
||||||
|
generate: (p) => renderEmoji(str(p, 'emoji'), Math.trunc(num(p, 'size')))
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'placeholder-png',
|
||||||
|
title: 'Create Placeholder PNG',
|
||||||
|
description:
|
||||||
|
'Generates a placeholder rectangle with its dimensions printed in the center.',
|
||||||
|
category: 'generate',
|
||||||
|
sourceMode: 'none',
|
||||||
|
domOnly: true,
|
||||||
|
params: [
|
||||||
|
{ id: 'width', label: 'Width', type: 'number', min: 1, max: 5000, step: 1, default: 800 },
|
||||||
|
{ id: 'height', label: 'Height', type: 'number', min: 1, max: 5000, step: 1, default: 400 },
|
||||||
|
{ id: 'backgroundColor', label: 'Background', type: 'color', default: '#dfe2e8' },
|
||||||
|
{ id: 'color', label: 'Text color', type: 'color', default: '#5c6470' },
|
||||||
|
{ id: 'showText', label: 'Print dimensions', type: 'checkbox', default: true }
|
||||||
|
],
|
||||||
|
generate: (p) => {
|
||||||
|
const w = Math.trunc(num(p, 'width'));
|
||||||
|
const h = Math.trunc(num(p, 'height'));
|
||||||
|
const label = renderTextToImage({
|
||||||
|
text: `${w} × ${h}`,
|
||||||
|
fontSize: Math.max(12, Math.round(Math.min(w, h) * 0.14)),
|
||||||
|
font: 'sans',
|
||||||
|
bold: true,
|
||||||
|
color: str(p, 'color'),
|
||||||
|
backgroundColor: str(p, 'backgroundColor'),
|
||||||
|
transparentBg: false,
|
||||||
|
padding: 0
|
||||||
|
});
|
||||||
|
return changeCanvasSize(label, w, h, 'center');
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: 'linear-gradient-png',
|
id: 'linear-gradient-png',
|
||||||
title: 'Create gradient PNG',
|
title: 'Create gradient PNG',
|
||||||
@@ -1324,6 +1481,86 @@ export const TOOLS: ToolEntry[] = [
|
|||||||
str(p, 'direction') === 'vertical' ? 'vertical' : 'horizontal'
|
str(p, 'direction') === 'vertical' ? 'vertical' : 'horizontal'
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'color-spectrum-png',
|
||||||
|
title: 'Color Spectrum PNG',
|
||||||
|
description:
|
||||||
|
'Full hue rainbow 0–360° along the chosen axis with adjustable saturation and lightness.',
|
||||||
|
category: 'generate',
|
||||||
|
sourceMode: 'none',
|
||||||
|
params: [
|
||||||
|
{ id: 'width', label: 'Width', type: 'number', min: 1, max: 5000, step: 1, default: 1024 },
|
||||||
|
{ id: 'height', label: 'Height', type: 'number', min: 1, max: 5000, step: 1, default: 128 },
|
||||||
|
{
|
||||||
|
id: 'direction',
|
||||||
|
label: 'Direction',
|
||||||
|
type: 'select',
|
||||||
|
default: 'horizontal',
|
||||||
|
options: [
|
||||||
|
{ value: 'horizontal', label: 'Horizontal' },
|
||||||
|
{ value: 'vertical', label: 'Vertical' }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{ id: 'saturation', label: 'Saturation, %', type: 'slider', min: 0, max: 100, step: 1, default: 100 },
|
||||||
|
{ id: 'lightness', label: 'Lightness, %', type: 'slider', min: 0, max: 100, step: 1, default: 50 }
|
||||||
|
],
|
||||||
|
generate: (p) =>
|
||||||
|
colorSpectrum(
|
||||||
|
Math.trunc(num(p, 'width')),
|
||||||
|
Math.trunc(num(p, 'height')),
|
||||||
|
str(p, 'direction') === 'vertical' ? 'vertical' : 'horizontal',
|
||||||
|
num(p, 'saturation'),
|
||||||
|
num(p, 'lightness')
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'random-colors-png',
|
||||||
|
title: 'Random Color Blocks PNG',
|
||||||
|
description:
|
||||||
|
'Fills the canvas with random vivid color blocks. Deterministic by seed.',
|
||||||
|
category: 'generate',
|
||||||
|
sourceMode: 'none',
|
||||||
|
params: [
|
||||||
|
{ id: 'width', label: 'Width', type: 'number', min: 1, max: 5000, step: 1, default: 512 },
|
||||||
|
{ id: 'height', label: 'Height', type: 'number', min: 1, max: 5000, step: 1, default: 512 },
|
||||||
|
{ id: 'blockSize', label: 'Block size, px', type: 'slider', min: 4, max: 256, step: 2, default: 64 },
|
||||||
|
{ id: 'seed', label: 'Seed', type: 'number', min: 0, max: 999999999, step: 1, default: 7 }
|
||||||
|
],
|
||||||
|
generate: (p) =>
|
||||||
|
randomColorBlocks(
|
||||||
|
Math.trunc(num(p, 'width')),
|
||||||
|
Math.trunc(num(p, 'height')),
|
||||||
|
num(p, 'blockSize'),
|
||||||
|
num(p, 'seed')
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'draw-grid-png',
|
||||||
|
title: 'Draw Grid PNG',
|
||||||
|
description:
|
||||||
|
'Draws a grid with custom columns, rows and line width on a transparent or white background.',
|
||||||
|
category: 'generate',
|
||||||
|
sourceMode: 'none',
|
||||||
|
params: [
|
||||||
|
{ id: 'width', label: 'Width', type: 'number', min: 1, max: 5000, step: 1, default: 512 },
|
||||||
|
{ id: 'height', label: 'Height', type: 'number', min: 1, max: 5000, step: 1, default: 512 },
|
||||||
|
{ id: 'cols', label: 'Columns', type: 'slider', min: 1, max: 64, step: 1, default: 8 },
|
||||||
|
{ id: 'rows', label: 'Rows', type: 'slider', min: 1, max: 64, step: 1, default: 8 },
|
||||||
|
{ id: 'lineWidth', label: 'Line width, px', type: 'slider', min: 1, max: 40, step: 1, default: 2 },
|
||||||
|
{ id: 'color', label: 'Line color', type: 'color', default: '#111318' },
|
||||||
|
{ id: 'transparentBg', label: 'Transparent background', type: 'checkbox', default: true }
|
||||||
|
],
|
||||||
|
generate: (p) =>
|
||||||
|
drawGrid(
|
||||||
|
Math.trunc(num(p, 'width')),
|
||||||
|
Math.trunc(num(p, 'height')),
|
||||||
|
num(p, 'cols'),
|
||||||
|
num(p, 'rows'),
|
||||||
|
num(p, 'lineWidth'),
|
||||||
|
str(p, 'color'),
|
||||||
|
bool(p, 'transparentBg')
|
||||||
|
)
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: 'color-wheel-png',
|
id: 'color-wheel-png',
|
||||||
title: 'Color Wheel PNG',
|
title: 'Color Wheel PNG',
|
||||||
@@ -1685,7 +1922,7 @@ export const TOOLS: ToolEntry[] = [
|
|||||||
title: 'Watermark Image PNG',
|
title: 'Watermark Image PNG',
|
||||||
description:
|
description:
|
||||||
'Overlays another PNG (logo/signature) on top: scale from canvas width, opacity, 3×3 position. The mark lives only while the page is open — after restoring a chain, pick it again.',
|
'Overlays another PNG (logo/signature) on top: scale from canvas width, opacity, 3×3 position. The mark lives only while the page is open — after restoring a chain, pick it again.',
|
||||||
category: 'text',
|
category: 'alpha',
|
||||||
domOnly: true,
|
domOnly: true,
|
||||||
needsOverlaySource: true,
|
needsOverlaySource: true,
|
||||||
params: [
|
params: [
|
||||||
|
|||||||
@@ -38,6 +38,7 @@
|
|||||||
Scan,
|
Scan,
|
||||||
Scissors,
|
Scissors,
|
||||||
SearchCheck,
|
SearchCheck,
|
||||||
|
FaceSlightlySmiling,
|
||||||
Square,
|
Square,
|
||||||
Stamp,
|
Stamp,
|
||||||
Star,
|
Star,
|
||||||
@@ -127,6 +128,17 @@ export const TOOL_ICONS: Record<string, typeof AppWindow> = {
|
|||||||
'change-aspect-ratio-png': Scaling,
|
'change-aspect-ratio-png': Scaling,
|
||||||
'swap-orientation-png': RotateCw,
|
'swap-orientation-png': RotateCw,
|
||||||
'symmetric-copy-png': FlipHorizontal2,
|
'symmetric-copy-png': FlipHorizontal2,
|
||||||
|
'png-to-bytes': Binary,
|
||||||
|
'bytes-to-png': Binary,
|
||||||
|
'png-to-rgb-values': Hash,
|
||||||
|
'rgb-values-to-png': Hash,
|
||||||
|
'verify-is-png': SearchCheck,
|
||||||
|
'text-to-png': Type,
|
||||||
|
'emoji-to-png': FaceSlightlySmiling,
|
||||||
|
'placeholder-png': Frame,
|
||||||
|
'color-spectrum-png': Rainbow,
|
||||||
|
'random-colors-png': Dices,
|
||||||
|
'draw-grid-png': Grid3x3,
|
||||||
'circle-mask-png': Circle,
|
'circle-mask-png': Circle,
|
||||||
'square-mask-png': Square,
|
'square-mask-png': Square,
|
||||||
'star-mask-png': Star,
|
'star-mask-png': Star,
|
||||||
|
|||||||
Reference in New Issue
Block a user