mirror of
https://github.com/Ku6epXBOCTuK/easy-png-tools.git
synced 2026-09-14 21:46:35 +00:00
feat: add text draw core
This commit is contained in:
@@ -5,7 +5,8 @@ export type CategoryId =
|
|||||||
| 'geometry'
|
| 'geometry'
|
||||||
| 'analyze'
|
| 'analyze'
|
||||||
| 'generate'
|
| 'generate'
|
||||||
| 'filters';
|
| 'filters'
|
||||||
|
| 'text';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Порядок категорий в каталоге. Человекочитаемые названия живут в словарях
|
* Порядок категорий в каталоге. Человекочитаемые названия живут в словарях
|
||||||
@@ -17,6 +18,7 @@ export const CATEGORIES: readonly CategoryId[] = [
|
|||||||
'color',
|
'color',
|
||||||
'geometry',
|
'geometry',
|
||||||
'filters',
|
'filters',
|
||||||
|
'text',
|
||||||
'analyze',
|
'analyze',
|
||||||
'generate'
|
'generate'
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -14,6 +14,7 @@ export const en: Dict = {
|
|||||||
color: 'Color',
|
color: 'Color',
|
||||||
geometry: 'Geometry',
|
geometry: 'Geometry',
|
||||||
filters: 'Filters',
|
filters: 'Filters',
|
||||||
|
text: 'Text',
|
||||||
analyze: 'Analyze',
|
analyze: 'Analyze',
|
||||||
generate: 'Generate'
|
generate: 'Generate'
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ export const ru: Dict = {
|
|||||||
color: 'Цвет',
|
color: 'Цвет',
|
||||||
geometry: 'Геометрия',
|
geometry: 'Геометрия',
|
||||||
filters: 'Фильтры',
|
filters: 'Фильтры',
|
||||||
|
text: 'Текст',
|
||||||
analyze: 'Анализ',
|
analyze: 'Анализ',
|
||||||
generate: 'Генерация'
|
generate: 'Генерация'
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user