feat: add text and generation tools
This commit is contained in:
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user