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
+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);
}