From 38e7cd20d28e7fc26d3cbfb3ec960a2b136d1202 Mon Sep 17 00:00:00 2001 From: Ku6epXBOCTuK Date: Wed, 26 Aug 2026 00:22:28 +0500 Subject: [PATCH] feat: add text draw core --- web/src/lib/categories.ts | 4 +- web/src/lib/core/domText.ts | 119 ++++++++++++++++++++++++++++++ web/src/lib/core/textdraw.test.ts | 66 +++++++++++++++++ web/src/lib/core/textdraw.ts | 109 +++++++++++++++++++++++++++ web/src/lib/i18n/en.ts | 1 + web/src/lib/i18n/ru.ts | 1 + 6 files changed, 299 insertions(+), 1 deletion(-) create mode 100644 web/src/lib/core/domText.ts create mode 100644 web/src/lib/core/textdraw.test.ts create mode 100644 web/src/lib/core/textdraw.ts diff --git a/web/src/lib/categories.ts b/web/src/lib/categories.ts index 8a6fda0..443a19c 100644 --- a/web/src/lib/categories.ts +++ b/web/src/lib/categories.ts @@ -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' ]; diff --git a/web/src/lib/core/domText.ts b/web/src/lib/core/domText.ts new file mode 100644 index 0000000..640f88b --- /dev/null +++ b/web/src/lib/core/domText.ts @@ -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 = { + 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 { + 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); +} diff --git a/web/src/lib/core/textdraw.test.ts b/web/src/lib/core/textdraw.test.ts new file mode 100644 index 0000000..9ef206e --- /dev/null +++ b/web/src/lib/core/textdraw.test.ts @@ -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); + }); +}); diff --git a/web/src/lib/core/textdraw.ts b/web/src/lib/core/textdraw.ts new file mode 100644 index 0000000..8736e3b --- /dev/null +++ b/web/src/lib/core/textdraw.ts @@ -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; +} diff --git a/web/src/lib/i18n/en.ts b/web/src/lib/i18n/en.ts index f0eccbc..a91046b 100644 --- a/web/src/lib/i18n/en.ts +++ b/web/src/lib/i18n/en.ts @@ -14,6 +14,7 @@ export const en: Dict = { color: 'Color', geometry: 'Geometry', filters: 'Filters', + text: 'Text', analyze: 'Analyze', generate: 'Generate' }, diff --git a/web/src/lib/i18n/ru.ts b/web/src/lib/i18n/ru.ts index 5bb862a..37b6a21 100644 --- a/web/src/lib/i18n/ru.ts +++ b/web/src/lib/i18n/ru.ts @@ -14,6 +14,7 @@ export const ru: Dict = { color: 'Цвет', geometry: 'Геометрия', filters: 'Фильтры', + text: 'Текст', analyze: 'Анализ', generate: 'Генерация' },