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.
|
||||
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
@@ -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, вне планов
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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'
|
||||
},
|
||||
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.',
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 {
|
||||
|
||||
+238
-1
@@ -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 {
|
||||
@@ -161,6 +173,8 @@ export type ToolEntry = {
|
||||
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;
|
||||
/** Текстовый источник + текстовый результат без промежуточного изображения. */
|
||||
textToText?: (text: string) => Promise<string> | string;
|
||||
preview?: (
|
||||
img: PixelImage,
|
||||
params: Record<string, unknown>
|
||||
@@ -601,6 +615,53 @@ export const TOOLS: ToolEntry[] = [
|
||||
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',
|
||||
title: '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',
|
||||
@@ -1293,6 +1365,91 @@ export const TOOLS: ToolEntry[] = [
|
||||
],
|
||||
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',
|
||||
title: 'Create 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 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',
|
||||
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: [
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user