/** * エフェクト開発規約 * ================ * * 1. 基本方針 * ----------- * - BaseEffectクラスは全てのエフェクトの基底クラスとして機能します * - 文字の配置や描画に関する基本ロジックは全てBaseEffectクラスで管理します * - 継承先のエフェクトクラスでは、見た目に関する設定のみを行います * * 2. 継承時のルール * ---------------- * - getPaddingメソッドは継承先でオーバーライドしないでください * - renderText, renderGlow, renderMainText, renderStrokeメソッドは継承先でオーバーライドしないでください * - calculateSize, calculateCoordinatesメソッドは継承先でオーバーライドしないでください * * 3. 継承先での実装 * ---------------- * 以下のメソッドのみを実装/カスタマイズしてください: * * a) constructor * - グローエフェクト(this.glowOptions)の設定 * - 縁取り(this.strokeOptions)の設定 * * b) setupContext * - フォントの基本設定(必須) * - グラデーションなどの塗りつぶしスタイルの設定 * * c) applySpecialEffect * - 追加の特殊効果が必要な場合のみ実装 * - 基本的な描画が完了した後に呼び出されます * * 4. オプション設定 * ---------------- * glowOptions = { * color: string, // グローの色(16進カラーコード) * blur: number, // ぼかしの強さ * iterations: number // 重ねがけの回数 * } * * strokeOptions = { * color: string, // 縁取りの色(16進カラーコード) * width: number // 縁取りの太さ * } * * 5. 縦書き対応 * ------------ * - 縦書きの処理は全てBaseEffectクラスで管理されています * - 継承先で縦書き用の特別な処理は実装しないでください */ /** * 基本的なエフェクトクラス */ export class BaseEffect { constructor() { this.coordinates = []; } /** * @param {CanvasRenderingContext2D} ctx - キャンバスコンテキスト * @param {string} text - レンダリングするテキスト * @param {Object} options - オプション * @param {string} options.font - フォントファミリー * @param {number} options.fontSize - フォントサイズ * @param {boolean} options.vertical - 縦書きモード * @returns {Object} - キャンバスのサイズ情報 */ 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 }); } // 行間を追加(行数 - 1)* 行間スペース 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 }; } /** * 特殊文字(縦書き時に回転が必要な文字)かどうかを判定 * @private */ isRotatableCharacter(char) { // 長音記号、ハイフン、チルダ、マイナス記号など const rotatableChars = ['ー', '-', '~', '―', '‐', '−', '─', 'ー']; return rotatableChars.includes(char); } /** * 文字の座標を計算 * @private */ 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(); } } } /** * 16進カラーコードをRGB形式に変換 */ 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'; } /** * エフェクトを適用する * @param {string} text - レンダリングするテキスト * @param {Object} options - オプション * @returns {Promise} - 生成された画像のHTMLCanvasElement */ 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); // canvasを返す return canvas; } /** * パディング値を取得 * @returns {number} パディング値 */ getPadding() { return 60; } /** * コンテキストの基本設定 * @param {CanvasRenderingContext2D} ctx * @param {Object} options */ async setupContext(ctx, options) { ctx.font = `${options.fontSize}px "${options.font}"`; ctx.fillStyle = '#000000'; ctx.textBaseline = 'top'; } /** * エフェクト固有の処理 * @param {CanvasRenderingContext2D} ctx * @param {HTMLCanvasElement} canvas * @param {Object} options */ async applySpecialEffect(ctx, canvas, options) { // デフォルトでは何もしない } }