| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
|
|
| |
| |
|
|
| export class BaseEffect {
|
| constructor() {
|
| this.coordinates = [];
|
| }
|
|
|
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| calculateSize(ctx, text, options) {
|
| ctx.font = `${options.fontSize}px "${options.font}"`;
|
|
|
|
|
| const lines = text.split('\n');
|
| let maxWidth = 0;
|
| let totalHeight = 0;
|
| const lineMetrics = [];
|
|
|
|
|
| for (const line of lines) {
|
| const metrics = ctx.measureText(line);
|
| const lineHeight = metrics.actualBoundingBoxAscent + metrics.actualBoundingBoxDescent;
|
| maxWidth = Math.max(maxWidth, metrics.width);
|
| totalHeight += lineHeight;
|
| lineMetrics.push({ metrics, lineHeight });
|
| }
|
|
|
|
|
| const lineSpacing = options.fontSize * 0.2;
|
| totalHeight += (lines.length - 1) * lineSpacing;
|
|
|
|
|
| if (options.vertical) {
|
|
|
| const maxCharHeight = Math.max(...lines.map(line => {
|
| return Math.max(...[...line].map(char => {
|
| const metrics = ctx.measureText(char);
|
| return metrics.actualBoundingBoxAscent + metrics.actualBoundingBoxDescent;
|
| }));
|
| }));
|
|
|
| return {
|
| width: totalHeight,
|
| height: maxWidth * 1.5,
|
| metrics: lineMetrics,
|
| lineSpacing,
|
| lines,
|
| maxCharHeight
|
| };
|
| }
|
|
|
| return {
|
| width: maxWidth,
|
| height: totalHeight,
|
| metrics: lineMetrics,
|
| lineSpacing,
|
| lines
|
| };
|
| }
|
|
|
| |
| |
| |
|
|
| isRotatableCharacter(char) {
|
|
|
| const rotatableChars = ['ー', '-', '~', '―', '‐', '−', '─', 'ー'];
|
| return rotatableChars.includes(char);
|
| }
|
|
|
| |
| |
| |
|
|
| calculateCoordinates(ctx, lines, metrics, lineSpacing, padding) {
|
| this.coordinates = [];
|
| const verticalLetterSpacing = parseFloat(ctx.canvas.dataset.verticalSpacing) || 1.3;
|
|
|
| if (ctx.canvas.dataset.vertical === 'true') {
|
|
|
| let currentX = ctx.canvas.width - padding/2;
|
|
|
| for (let i = 0; i < lines.length; i++) {
|
| const line = lines[i];
|
| const { lineHeight } = metrics[i];
|
| const lineCoords = [];
|
|
|
|
|
| let totalLineHeight = 0;
|
| for (const char of line) {
|
| const metrics = ctx.measureText(char);
|
| const charHeight = metrics.actualBoundingBoxAscent + metrics.actualBoundingBoxDescent;
|
| const shouldRotate = this.isRotatableCharacter(char);
|
| totalLineHeight += shouldRotate ? metrics.width : charHeight;
|
| if (char !== line[line.length - 1]) {
|
| totalLineHeight += lineSpacing;
|
| }
|
| }
|
|
|
|
|
| let charY = padding + (ctx.canvas.height - totalLineHeight - padding * 2) / 2;
|
| for (const char of line) {
|
| const metrics = ctx.measureText(char);
|
| const charHeight = metrics.actualBoundingBoxAscent + metrics.actualBoundingBoxDescent * verticalLetterSpacing;
|
| const shouldRotate = this.isRotatableCharacter(char);
|
|
|
|
|
| if (shouldRotate) {
|
| lineCoords.push({
|
| char,
|
| x: currentX - charHeight,
|
| y: charY + metrics.width/2,
|
| width: metrics.width,
|
| height: charHeight,
|
| rotate: true
|
| });
|
| charY += metrics.width + lineSpacing;
|
| } else {
|
| lineCoords.push({
|
| char,
|
| x: currentX - metrics.width - metrics.width/4,
|
| y: charY - metrics.width/8,
|
| width: metrics.width,
|
| height: charHeight,
|
| rotate: false
|
| });
|
| charY += charHeight + lineSpacing;
|
| }
|
| }
|
|
|
| this.coordinates.push(lineCoords);
|
| currentX -= lineHeight + lineSpacing;
|
| }
|
| } else {
|
| let currentY = padding;
|
|
|
| for (let i = 0; i < lines.length; i++) {
|
| const line = lines[i];
|
| const { lineHeight } = metrics[i];
|
| const lineWidth = ctx.measureText(line).width;
|
|
|
|
|
| const x = (ctx.canvas.width - lineWidth) / 2;
|
|
|
| this.coordinates.push([{
|
| char: line,
|
| x: x,
|
| y: currentY,
|
| width: lineWidth,
|
| height: lineHeight,
|
| rotate: false
|
| }]);
|
|
|
| currentY += lineHeight + lineSpacing;
|
| }
|
| }
|
| }
|
|
|
| |
| |
|
|
| async renderText(ctx, lines, metrics, lineSpacing, padding) {
|
|
|
| this.calculateCoordinates(ctx, lines, metrics, lineSpacing, padding);
|
|
|
|
|
| await this.renderGlow(ctx);
|
|
|
|
|
| await this.renderMainText(ctx);
|
|
|
|
|
| await this.renderStroke(ctx);
|
| }
|
|
|
| |
| |
|
|
| async renderGlow(ctx) {
|
| if (!this.glowOptions) return;
|
|
|
| const { color, blur, iterations } = this.glowOptions;
|
| ctx.shadowColor = color;
|
| ctx.shadowOffsetX = 0;
|
| ctx.shadowOffsetY = 0;
|
|
|
|
|
| for (let j = 0; j < iterations; j++) {
|
| ctx.shadowBlur = blur - (blur * j / iterations);
|
| ctx.fillStyle = `rgba(${this.hexToRgb(color)}, ${1/iterations})`;
|
|
|
| for (const lineCoords of this.coordinates) {
|
| for (const coord of lineCoords) {
|
| ctx.save();
|
| if (coord.rotate) {
|
| ctx.translate(coord.x, coord.y);
|
| ctx.rotate(Math.PI/2);
|
| ctx.fillText(coord.char, -coord.width/2, 0);
|
| } else {
|
| ctx.fillText(coord.char, coord.x, coord.y);
|
| }
|
| ctx.restore();
|
| }
|
| }
|
| }
|
|
|
|
|
| ctx.globalCompositeOperation = 'lighter';
|
| ctx.shadowBlur = blur * 1.5;
|
| ctx.globalAlpha = 0.3;
|
|
|
| for (const lineCoords of this.coordinates) {
|
| for (const coord of lineCoords) {
|
| ctx.save();
|
| if (coord.rotate) {
|
| ctx.translate(coord.x, coord.y);
|
| ctx.rotate(Math.PI/2);
|
| ctx.fillText(coord.char, -coord.width/2, 0);
|
| } else {
|
| ctx.fillText(coord.char, coord.x, coord.y);
|
| }
|
| ctx.restore();
|
| }
|
| }
|
|
|
|
|
| ctx.globalCompositeOperation = 'source-over';
|
| ctx.globalAlpha = 1.0;
|
| ctx.shadowBlur = 0;
|
| ctx.shadowColor = 'transparent';
|
| }
|
|
|
| |
| |
|
|
| async renderMainText(ctx) {
|
| for (const lineCoords of this.coordinates) {
|
| for (const coord of lineCoords) {
|
| ctx.save();
|
| if (coord.rotate) {
|
| ctx.translate(coord.x, coord.y);
|
| ctx.rotate(Math.PI/2);
|
| ctx.fillText(coord.char, -coord.width/2, 0);
|
| } else {
|
| ctx.fillText(coord.char, coord.x, coord.y);
|
| }
|
| ctx.restore();
|
| }
|
| }
|
| }
|
|
|
| |
| |
|
|
| async renderStroke(ctx) {
|
| if (!this.strokeOptions) return;
|
|
|
| const { color, width } = this.strokeOptions;
|
| const originalLineWidth = ctx.lineWidth;
|
| const originalStrokeStyle = ctx.strokeStyle;
|
|
|
|
|
| ctx.strokeStyle = color;
|
| ctx.lineWidth = width;
|
| ctx.lineJoin = 'round';
|
| ctx.lineCap = 'round';
|
|
|
|
|
| const iterations = 8;
|
| const angleStep = (Math.PI * 2) / iterations;
|
|
|
| for (let i = 0; i < iterations; i++) {
|
| const angle = i * angleStep;
|
| const offsetX = Math.cos(angle) * (width * 0.5);
|
| const offsetY = Math.sin(angle) * (width * 0.5);
|
|
|
| for (const lineCoords of this.coordinates) {
|
| for (const coord of lineCoords) {
|
| ctx.save();
|
| if (coord.rotate) {
|
| ctx.translate(coord.x + offsetX, coord.y + offsetY);
|
| ctx.rotate(Math.PI/2);
|
| ctx.strokeText(coord.char, -coord.width/2, 0);
|
| } else {
|
| ctx.strokeText(coord.char, coord.x + offsetX, coord.y + offsetY);
|
| }
|
| ctx.restore();
|
| }
|
| }
|
| }
|
|
|
|
|
| ctx.lineWidth = originalLineWidth;
|
| ctx.strokeStyle = originalStrokeStyle;
|
| ctx.lineJoin = 'miter';
|
| ctx.lineCap = 'butt';
|
| }
|
|
|
| |
| |
|
|
| hexToRgb(hex) {
|
| const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
|
| return result ?
|
| `${parseInt(result[1], 16)}, ${parseInt(result[2], 16)}, ${parseInt(result[3], 16)}` :
|
| '0, 0, 0';
|
| }
|
|
|
| |
| |
| |
| |
| |
|
|
| async apply(text, options) {
|
| const padding = this.getPadding();
|
| const canvas = document.createElement('canvas');
|
| const ctx = canvas.getContext('2d');
|
|
|
|
|
| canvas.dataset.vertical = options.vertical.toString();
|
| canvas.dataset.verticalSpacing = options.verticalSpacing?.toString() || "1.3";
|
|
|
|
|
| const { width, height, metrics, lineSpacing, lines } = this.calculateSize(ctx, text, options);
|
| canvas.width = width + padding * 2;
|
| canvas.height = height + padding * 2;
|
|
|
|
|
| await this.setupContext(ctx, options);
|
|
|
|
|
| await this.renderText(ctx, lines, metrics, lineSpacing, padding);
|
|
|
|
|
| await this.applySpecialEffect(ctx, canvas, options);
|
|
|
|
|
| return canvas;
|
| }
|
|
|
| |
| |
| |
|
|
| getPadding() {
|
| return 60;
|
| }
|
|
|
| |
| |
| |
| |
|
|
| async setupContext(ctx, options) {
|
| ctx.font = `${options.fontSize}px "${options.font}"`;
|
| ctx.fillStyle = '#000000';
|
| ctx.textBaseline = 'top';
|
| }
|
|
|
| |
| |
| |
| |
| |
|
|
| async applySpecialEffect(ctx, canvas, options) {
|
|
|
| }
|
| } |