feat: add text and generation tools

This commit is contained in:
2026-08-26 16:08:27 +05:00
parent 56ebe5b53b
commit 2b67414e79
14 changed files with 734 additions and 22 deletions
+1 -1
View File
@@ -42,7 +42,7 @@
Состав: pixelate, color-blocks, randomize-pixels (seed), add-noise, feather-edges, clean-edges, silhouette.
Shadow/glow — сюда же, если потянет этап: оба = размытая альфа + смещение + цвет (ядро blur уже есть).
### W7. Конвертеры и генераторы — ~10 инструментов, M
### W7. Конвертеры и генераторы — ВЫПОЛНЕНА (11 инструментов)
PNG↔bytes, PNG↔rgb-values (текстовые результаты), verify-is-png (анализ сигнатуры),
text-to-png (domText без входной картинки), emoji-to-png, placeholder-png,
+13 -13
View File
@@ -21,6 +21,10 @@
- png-to-base64 / base64-to-png — строка
- png-to-data-uri / data-uri-to-png — строка
- 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
- feather-edges-png — radius (размытие только альфы)
- clean-edges-png — radius (defringe: RGB от ближайшего непрозрачного)
- watermark-image-png — вторая картинка-знак (загружается на странице), scale, opacity, position, margin
- despeckle-alpha-png / close-holes-png — radius
- center-by-alpha-png — без параметров
- round-corners-png — radius
@@ -118,13 +123,18 @@
- single-color-png — width, height, color
- random-noise-png — width, height, seed
- 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
- 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-image-png — вторая картинка-знак (загружается на странице), scale, opacity, position, margin
### Палитры и цветовые утилиты
@@ -170,21 +180,12 @@
- change-quality — честная семантика для lossless (см. план-гапы §риск)
- 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)
- 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-энкодер руками)
- gif-to-frames — MEDIUM (мультифайловый вывод → идея)
- change-bit-depth — MEDIUM (пересборка PNG)
@@ -192,7 +193,6 @@
### Прочее единичное
- pick-a-color — пипетка уже есть в превью; отдельная страница не планируется (покрыто)
- watermark-image — в плане волны 4 (этап D)
- preview/test на цветных фонах — фича превью, не инструмент (решить позже)
- extract-barcode — HARD, вне планов
+14 -1
View File
@@ -153,9 +153,22 @@
}
async function handleTextSubmit(text: string) {
if (!isTextSource || !tool.runFromText) return;
if (!isTextSource) return;
errorText = '';
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 {
source = await tool.runFromText(text, defaultParams(tool));
values = defaultParams(tool);
@@ -15,7 +15,7 @@
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 activeIndex = $state(0);
+46
View File
@@ -92,6 +92,52 @@ export interface TileTextOptions extends Omit<TextBlockOptions, 'position' | 'ma
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 {
mark: PixelImage;
scalePercent: number;
+44
View File
@@ -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);
});
});
+106
View File
@@ -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;
}
+63
View File
@@ -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]);
});
});
+89
View File
@@ -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);
}
+6
View File
@@ -148,6 +148,12 @@ export const en: Dict = {
sizePositive: 'Dimensions must be positive and finite'
},
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': {
results: {
grayscaleYes: 'Yes — all pixels are shades of gray.',
+94
View File
@@ -759,6 +759,100 @@ export const ru: Dict = {
'Заливает все видимые пиксели одним цветом, сохраняя их прозрачность — мгновенный силуэт.',
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': {
title: 'Показать прозрачные области PNG',
description:
+3 -1
View File
@@ -26,7 +26,9 @@ describe('реестр инструментов', () => {
it.each(TOOLS.map((t) => [t.id, t] as const))('%s: исполнители определены', (_, tool) => {
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') {
expect(typeof tool.generate).toBe('function');
} else {
+242 -5
View File
@@ -56,9 +56,21 @@ import {
drawImageWatermark,
drawTextBlock,
drawTextTile,
renderEmoji,
renderTextToImage,
type TextFont
} from './core/domText';
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 type { Position9 } from './core/textdraw';
import {
@@ -160,7 +172,9 @@ export type ToolEntry = {
run?: (img: PixelImage, 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;
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?: (
img: PixelImage,
params: Record<string, unknown>
@@ -598,8 +612,55 @@ export const TOOLS: ToolEntry[] = [
params: [
{ id: 'width', label: 'Image width', type: 'number', min: 1, max: 10000, step: 1, default: 1 }
],
runFromText: (text, p) => hexToPixels(text, Math.trunc(Number(p['width']))),
run: (img) => clonePixelImage(img)
runFromText: (text, p) => hexToPixels(text, Math.trunc(Number(p['width']))),
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',
@@ -1230,6 +1291,17 @@ export const TOOLS: ToolEntry[] = [
],
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',
title: 'PNG info',
@@ -1291,7 +1363,92 @@ export const TOOLS: ToolEntry[] = [
{ id: 'height', label: 'Height', type: 'number', min: 1, max: 5000, step: 1, default: 512 },
{ id: 'seed', label: 'Seed', type: 'number', min: 0, max: 999999999, step: 1, default: 1 }
],
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',
@@ -1324,6 +1481,86 @@ export const TOOLS: ToolEntry[] = [
str(p, 'direction') === 'vertical' ? 'vertical' : 'horizontal'
)
},
{
id: 'color-spectrum-png',
title: 'Color Spectrum PNG',
description:
'Full hue rainbow 0360° 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',
title: 'Color Wheel PNG',
@@ -1685,7 +1922,7 @@ export const TOOLS: ToolEntry[] = [
title: 'Watermark Image PNG',
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.',
category: 'text',
category: 'alpha',
domOnly: true,
needsOverlaySource: true,
params: [
+12
View File
@@ -38,6 +38,7 @@
Scan,
Scissors,
SearchCheck,
FaceSlightlySmiling,
Square,
Stamp,
Star,
@@ -127,6 +128,17 @@ export const TOOL_ICONS: Record<string, typeof AppWindow> = {
'change-aspect-ratio-png': Scaling,
'swap-orientation-png': RotateCw,
'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,
'square-mask-png': Square,
'star-mask-png': Star,