LogoMaker / effects /base.js
SenY's picture
ダミーのポストプロセス実行完了
92a523d
raw
history blame
14.2 kB
/**
* エフェクト開発規約
* ================
*
* 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>} - 生成された画像の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) {
// デフォルトでは何もしない
}
}