feat: add text draw core

This commit is contained in:
2026-08-26 00:22:28 +05:00
parent 2e980fb115
commit 38e7cd20d2
6 changed files with 299 additions and 1 deletions
+3 -1
View File
@@ -5,7 +5,8 @@ export type CategoryId =
| 'geometry'
| 'analyze'
| 'generate'
| 'filters';
| 'filters'
| 'text';
/**
* Порядок категорий в каталоге. Человекочитаемые названия живут в словарях
@@ -17,6 +18,7 @@ export const CATEGORIES: readonly CategoryId[] = [
'color',
'geometry',
'filters',
'text',
'analyze',
'generate'
];
+119
View File
@@ -0,0 +1,119 @@
import { ToolError } from './errors';
import type { PixelImage } from './types';
import { anchorOrigin, tileGrid, wrapText, type Position9 } from './textdraw';
export type TextFont = 'sans' | 'serif' | 'mono';
const FONT_STACKS: Record<TextFont, string> = {
sans: 'system-ui, -apple-system, "Segoe UI", Roboto, sans-serif',
serif: 'Georgia, "Times New Roman", serif',
mono: 'ui-monospace, "Cascadia Code", Consolas, monospace'
};
function ctx2d(w: number, h: number): { canvas: HTMLCanvasElement; ctx: CanvasRenderingContext2D } {
const canvas = document.createElement('canvas');
canvas.width = w;
canvas.height = h;
const ctx = canvas.getContext('2d', { willReadFrequently: true });
if (!ctx) throw new ToolError('errors.noCanvasCtx');
return { canvas, ctx };
}
function toPixelImage(canvas: HTMLCanvasElement): PixelImage {
const ctx = canvas.getContext('2d');
if (!ctx) throw new ToolError('errors.noCanvasCtx');
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
return { width: imageData.width, height: imageData.height, data: imageData.data };
}
export function fontString(size: number, font: TextFont, bold: boolean): string {
return `${bold ? '700 ' : '400 '}${size}px ${FONT_STACKS[font]}`;
}
export interface TextBlockOptions {
text: string;
fontSize: number;
font: TextFont;
bold: boolean;
color: string;
opacityPercent: number;
position: Position9;
margin: number;
maxWidthPercent?: number;
plateColor?: string;
plateOpacityPercent?: number;
angleDeg?: number;
}
/** Одна надпись (с автопереносом и опциональной плашкой) поверх изображения. */
export function drawTextBlock(img: PixelImage, o: TextBlockOptions): PixelImage {
const { canvas, ctx } = ctx2d(img.width, img.height);
ctx.putImageData(new ImageData(new Uint8ClampedArray(img.data), img.width, img.height), 0, 0);
ctx.font = fontString(o.fontSize, o.font, o.bold);
const maxWidth = ((o.maxWidthPercent ?? 90) / 100) * img.width;
const lines = wrapText(o.text, maxWidth, (s) => ctx.measureText(s).width);
const lineH = o.fontSize * 1.25;
const ascent = o.fontSize * 0.8;
const blockW = Math.min(
maxWidth,
lines.reduce((max, s) => Math.max(max, ctx.measureText(s).width), 0)
);
const blockH = lines.length * lineH;
const origin = anchorOrigin(o.position, blockW, blockH, img.width, img.height, o.margin);
ctx.save();
ctx.globalAlpha = o.opacityPercent / 100;
if (o.angleDeg) {
ctx.translate(origin.x + blockW / 2, origin.y + blockH / 2);
ctx.rotate((o.angleDeg * Math.PI) / 180);
ctx.translate(-(origin.x + blockW / 2), -(origin.y + blockH / 2));
}
if (o.plateColor && (o.plateOpacityPercent ?? 0) > 0) {
ctx.globalAlpha = (o.plateOpacityPercent ?? 0) / 100;
ctx.fillStyle = o.plateColor;
ctx.fillRect(origin.x, origin.y, blockW, blockH);
ctx.globalAlpha = o.opacityPercent / 100;
}
ctx.fillStyle = o.color;
ctx.textBaseline = 'alphabetic';
lines.forEach((line, i) => {
ctx.fillText(line, origin.x, origin.y + ascent + i * lineH);
});
ctx.restore();
return toPixelImage(canvas);
}
export interface TileTextOptions extends Omit<TextBlockOptions, 'position' | 'margin' | 'angleDeg'> {
stepX: number;
stepY: number;
angleDeg: number;
}
/** Повторяющаяся диагональная плитка текста на весь холст. */
export function drawTextTile(img: PixelImage, o: TileTextOptions): PixelImage {
const { canvas, ctx } = ctx2d(img.width, img.height);
ctx.putImageData(new ImageData(new Uint8ClampedArray(img.data), img.width, img.height), 0, 0);
ctx.font = fontString(o.fontSize, o.font, o.bold);
const sample = o.text.length > 0 ? o.text : ' ';
const blockW = ctx.measureText(sample).width;
const blockH = o.fontSize * 1.4;
const points = tileGrid(img.width, img.height, o.angleDeg, o.stepX, o.stepY, blockW, blockH);
ctx.save();
ctx.translate(img.width / 2, img.height / 2);
ctx.rotate((o.angleDeg * Math.PI) / 180);
ctx.globalAlpha = o.opacityPercent / 100;
ctx.fillStyle = o.color;
ctx.textBaseline = 'middle';
for (const p of points) {
ctx.fillText(sample, p.x - blockW / 2, p.y);
}
ctx.restore();
return toPixelImage(canvas);
}
+66
View File
@@ -0,0 +1,66 @@
import { describe, expect, it } from 'vitest';
import { anchorOrigin, tileGrid, wrapText } from './textdraw';
const measure = (s: string) => s.length * 10;
describe('anchorOrigin', () => {
it('углы и отступ считаются от краёв', () => {
expect(anchorOrigin('top-left', 100, 50, 400, 300, 30)).toEqual({ x: 30, y: 30 });
expect(anchorOrigin('top-right', 100, 50, 400, 300, 30)).toEqual({ x: 270, y: 30 });
expect(anchorOrigin('bottom-left', 100, 50, 400, 300, 30)).toEqual({ x: 30, y: 220 });
expect(anchorOrigin('bottom-right', 100, 50, 400, 300, 30)).toEqual({ x: 270, y: 220 });
});
it('центрирование — ровно половина остатка', () => {
expect(anchorOrigin('center', 100, 50, 401, 301, 0)).toEqual({ x: 150.5, y: 125.5 });
expect(anchorOrigin('middle-left', 100, 50, 400, 300, 12)).toEqual({ x: 12, y: 125 });
expect(anchorOrigin('top-center', 100, 50, 400, 300, 8)).toEqual({ x: 150, y: 8 });
});
});
describe('wrapText', () => {
it('жадно набирает строки в пределах ширины', () => {
// measure: 10px за символ → строка ≤ 120px = 12 символов
expect(wrapText('один два три четыре пять', 120, measure)).toEqual([
'один два три',
'четыре пять'
]);
});
it('слово длиннее ширины уходит на отдельную строку целиком', () => {
expect(wrapText('короткое сверхдлинноеслово без переносов', 90, measure)).toEqual([
'короткое',
'сверхдлинноеслово',
'без',
'переносов'
]);
});
it('пустой и пробельный текст дают пустой массив', () => {
expect(wrapText('', 100, measure)).toEqual([]);
expect(wrapText(' \n\t ', 100, measure)).toEqual([]);
});
});
describe('tileGrid', () => {
it('стабильная сетка с шагом и центрированием', () => {
const pts = tileGrid(200, 200, 0, 60, 60, 80, 24);
expect(pts.length).toBeGreaterThan(0);
const xs = new Set(pts.map((p) => p.x));
const ys = new Set(pts.map((p) => p.y));
expect(xs.size).toBeGreaterThan(1);
expect(ys.size).toBeGreaterThan(1);
});
it('кап защищает от гигантского количества плиток', () => {
const pts = tileGrid(4000, 4000, 45, 8, 8, 100, 40);
expect(pts.length).toBeLessThanOrEqual(2500);
expect(pts.length).toBeGreaterThan(0);
});
it('обычные входные данные не триггерят кап', () => {
const pts = tileGrid(800, 600, 30, 140, 90, 160, 40);
expect(pts.length).toBeLessThanOrEqual(2500);
expect(pts.length).toBeGreaterThan(4);
});
});
+109
View File
@@ -0,0 +1,109 @@
export type Position9 =
| 'top-left'
| 'top-center'
| 'top-right'
| 'middle-left'
| 'center'
| 'middle-right'
| 'bottom-left'
| 'bottom-center'
| 'bottom-right';
/**
* Левый верхний угол контента размером contentW×contentH при размещении
* на холсте cw×ch с отступом margin согласно 9-позиционной сетке.
*/
export function anchorOrigin(
position: Position9,
contentW: number,
contentH: number,
cw: number,
ch: number,
margin: number
): { x: number; y: number } {
const h = position.endsWith('-left') ? 'left' : position.endsWith('-right') ? 'right' : 'center';
const v = position.startsWith('top-')
? 'top'
: position.startsWith('bottom-')
? 'bottom'
: 'middle';
const x = h === 'left' ? margin : h === 'right' ? cw - margin - contentW : (cw - contentW) / 2;
const y = v === 'top' ? margin : v === 'bottom' ? ch - margin - contentH : (ch - contentH) / 2;
return { x, y };
}
/**
* Жадный перенос текста по словам под ширину maxWidth.
* measure — инъекция измерителя ширины строки. Пустой текст → пустой массив строк.
*/
export function wrapText(
text: string,
maxWidth: number,
measure: (line: string) => number
): string[] {
const words = text.trim().split(/\s+/).filter((w) => w.length > 0);
if (words.length === 0) return [];
const lines: string[] = [];
let current = '';
for (const word of words) {
const candidate = current.length === 0 ? word : `${current} ${word}`;
if (measure(candidate) <= maxWidth || current.length === 0) {
current = candidate;
} else {
lines.push(current);
current = word;
}
}
if (current.length > 0) lines.push(current);
return lines;
}
export type TilePoint = { x: number; y: number };
const MAX_TILES = 2500;
/**
* Сетка позиций плитки водяного знака в повернутой системе координат:
* рисующий код один раз поворачивает контекст и ставит блоки в этих точках.
* Шаги автоматически увеличиваются, если расчетное количество плиток
* превышает кап — большие холсты не подвешивают страницу.
*/
export function tileGrid(
cw: number,
ch: number,
angleDeg: number,
stepX: number,
stepY: number,
blockW: number,
blockH: number
): TilePoint[] {
const diag = Math.sqrt(cw * cw + ch * ch);
const spanX = diag + blockW;
const spanY = diag + blockH;
let sx = Math.max(stepX, 1);
let sy = Math.max(stepY, 1);
for (let guard = 0; guard < 16; guard++) {
const count = Math.ceil(spanX / sx) * Math.ceil(spanY / sy);
if (count <= MAX_TILES) break;
const factor = Math.sqrt(count / MAX_TILES) * 1.02;
sx *= factor;
sy *= factor;
}
const cols = Math.max(1, Math.ceil(spanX / sx));
const rows = Math.max(1, Math.ceil(spanY / sy));
const startX = -spanX / 2 + (spanX - (cols - 1) * sx) / 2;
const startY = -spanY / 2 + (spanY - (rows - 1) * sy) / 2;
const points: TilePoint[] = [];
for (let r = 0; r < rows; r++) {
for (let c = 0; c < cols; c++) {
points.push({
x: startX + c * sx - cw / 2,
y: startY + r * sy - ch / 2
});
}
}
return points;
}
+1
View File
@@ -14,6 +14,7 @@ export const en: Dict = {
color: 'Color',
geometry: 'Geometry',
filters: 'Filters',
text: 'Text',
analyze: 'Analyze',
generate: 'Generate'
},
+1
View File
@@ -14,6 +14,7 @@ export const ru: Dict = {
color: 'Цвет',
geometry: 'Геометрия',
filters: 'Фильтры',
text: 'Текст',
analyze: 'Анализ',
generate: 'Генерация'
},