|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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.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;
|
|
ctx.strokeStyle = color;
|
|
ctx.lineWidth = width;
|
|
|
|
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.strokeText(coord.char, -coord.width/2, 0);
|
|
} else {
|
|
ctx.strokeText(coord.char, coord.x, coord.y);
|
|
}
|
|
ctx.restore();
|
|
}
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
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) {
|
|
|
|
}
|
|
} |