のんラボ

Canvas で画像と文字を合成する【縦書き横書き改行対応】

2023/01/20 2023/01/24 Canvas で画像と文字を合成する【縦書き横書き改行対応】

こんにちは。のんです。

今回は Canvas に画像と文字を合成する処理について、備忘録を兼ねて書いていこうと思います。

書こうと思ったキッカケは 今年の抱負メーカー です。

結構ユーザーがいるので、いい加減この使いにくい UI をなんとか使いやすくしようという魂胆です。

他のブログたちの差別化として、クラスベースでまとまった処理を書いています。よくネットに転がっている内容は上から順に流すだけのプログラムや関数が公開されているだけでした。

それをライブラリチックに流用しやすいように書いてますので、一部分だけでも参考にできれば幸いです。

実際にどんな感じで動くかは↑を実際に使ってみてね(宣伝)

アプリコードを公開するか、デモ用のページを公開するかは気が向いたらやります。

では早速処理を見ていきましょう。

✍ 結論(書いたコード)

いきなり結論ですが、

  • 自動改行
  • 縦書き
  • 横書き

に対応したコードがこちらです。

解説がみたい方はもっと下へどうぞ。

※ TypeScript です。JavaScript に直接貼り付けても動作しませんのであしからず。

WriteTextOnCanvas.tscopied.import sliceByNumber from './sliceByNumber';

type CanvasWriteProps = {
    canvas: HTMLCanvasElement
    image: HTMLImageElement
    font: string
    fontSize: number
    fontColor: string
}

export default class WriteTextOnCanvas {
  private props: CanvasWriteProps;
  private context: CanvasRenderingContext2D;
  private vertical = true;
  private maxCharsPerLine: number;
  private value: string;

  constructor(props: CanvasWriteProps) {
    this.props = props;
    this.context = this.props.canvas.getContext('2d');
    this.initialize();
  }

  initialize() {
    this.props.canvas.width = this.props.image.width;
    this.props.canvas.height = this.props.image.height;
    this.maxCharsPerLine = this.vertical
      ? Math.ceil((this.props.image.height - this.props.fontSize * 2) / this.props.fontSize)
      : Math.ceil((this.props.image.width - this.props.fontSize) / this.props.fontSize);
    this.context.font = this.props.fontSize + 'px ' + this.props.font;
    this.context.fillStyle = this.props.fontColor;
    this.context.drawImage(this.props.image, 0, 0);
    this.vertical
      ? this.context.translate(this.props.fontSize * (this.maxCharsPerLine - 1), this.props.fontSize * 1.5)
      : this.context.translate(this.props.fontSize * 0.5, 0);
  }

  changeImage(image: HTMLImageElement) {
    this.props.image = image;
    this.write(this.value);
  }

  changeFontSize(fontSize: number) {
    this.props.fontSize = fontSize;
    this.write(this.value);
  }

  changeMode(mode: string) {
    this.vertical = mode === 'vertical';
    this.write(this.value);
  }

  write(value: string) {
    this.value = value;
    this.initialize();

    const ambitions = value
      .split('\n')
      .map(value => [...value])
      .map(ambition => {
        if (ambition.length > this.maxCharsPerLine) {
          return sliceByNumber(ambition, this.maxCharsPerLine);
        }
        return [ambition];
      })
      .flat(1);

    ambitions.forEach(rows => {
      this.vertical
        ? this.context.translate(-this.props.fontSize, 0)
        : this.context.translate(0, this.props.fontSize);

      this.context.save();

      rows.forEach((value: string) => {
        if (this.vertical) {
          if (value.search(new RegExp(/[ー〜\-~「」[\]()()<><>{}{};:;:||==//__]/, 'g')) !== -1) {
            this.context.rotate((90 * Math.PI / 180));
            this.context.translate(-this.props.fontSize, 0);
            this.context.fillText(value, 0, 0);
            this.context.rotate(-1 * (90 * Math.PI / 180));
            this.context.translate(0, this.props.fontSize);
          } else if (value.search(new RegExp(/[。、.,]/, 'g')) !== -1) {
            this.context.fillText(value, this.props.fontSize * 0.7, -this.props.fontSize * 0.7);
          } else {
            this.context.fillText(value, 0, 0);
          }
        } else {
          this.context.fillText(value, 0, 0);
        }

        this.vertical
          ? this.context.translate(0, this.props.fontSize)
          : this.context.translate(this.props.fontSize, 0);
      });

      this.context.restore();
    });
  }

  toDataURL(): string {
    return this.props.canvas.toDataURL('image/png');
  }

  toBlob(callback: BlobCallback): void {
    return this.props.canvas.toBlob(callback);
  }

  text(): string {
    return this.value;
  }
}

sliceByNumber.ts 配列を渡した数値分ずつに分割する関数。

sliceByNumber.tscopied.export default function sliceByNumber(array, number) {
  const length = Math.ceil(array.length / number);
  return new Array(length)
    .fill(0)
    .map((_, i) => array.slice(i * number, (i + 1) * number));
}

🤠 使い方を簡単に(詳細解説は後述)

表示される HTML 。

copied.@php use App\Http\TwitterAuthRedirect\TwitterAuthRedirectAction; @endphp
<div class="ambition-form-container">
    <div class="ambition-canvas-container">
        <div>
            <label><input id="mode-toggle" type="checkbox">横書き</label>
        </div>
        <canvas id="ambition-canvas"></canvas>
    </div>

    <div>
        <form
            id="tweet-ambition"
            action="{{ route(TwitterAuthRedirectAction::class) }}"
            method="post"
            target="_blank"
            rel="noopener noreferrer"
        >
            @csrf
            <div>
                <div><small class="text-xs"><strong>連携したTwitterの情報は「抱負画像のツイート」、「ユーザー名の取得」にのみ使用</strong>し、アカウント情報の保持を一切行いません。</small></div>
                <div><small class="text-xs">※環境依存文字・絵文字などの入力で予期しない動作をする場合があります。</small></div>
            </div>
            <div class="mb-1">
            <textarea
                id="ambition"
                name="ambitionText"
                rows="10"
                class="ambition-textarea"
            >

改行とスペースを

上手に使うと

綺麗に見えます😃</textarea>
            </div>

            <div class="ambition-form-control-panel">
                <div class="mb-1">
                    <input type="hidden" name="ambitionImage"/>
                    <button type="submit" class="button button__twitter-blue">Twitter でつぶやく</button>
                </div>

                <div class="mb-1 text-right">
                    <button type="button" id="ambition-canvas-download" class="button">ダウンロード</button>
                </div>
            </div>
        </form>

        <div class="mb-1 text-right">
            <div>
                <button id="ambition-share" class="button">Twitter でシェアする</button>
            </div>
            <p><small class="text-xs">このボタンを使うときは縦書きだと見づらいかも。横書き推奨↑</small></p>
            <p><small class="text-xs">Twitter連携せずにつぶやけるようにしています。</small></p>
            <p><small class="text-xs">DLも活用して上手につぶやいてね!</small></p>
        </div>

    </div>
</div>

HTML の UI のイベントを検知して Canvas を操作する処理。

copied.import WriteTextOnCanvas from './utils/WriteTextOnCanvas';
import loadImage from './utils/loadImage';

type CreateAmbitionResponse = {
    ambitionUrl: string
}

document.body.onload = async () => {
  const fude = new FontFace('fude', 'url(../fonts/fude.ttf)');
  await fude.load();
  document.fonts.add(fude);

  const KouzanMouhituFontOTF = new FontFace('KouzanMouhituFontOTF', 'url(../fonts/KouzanMouhituFontOTF.otf)');
  await KouzanMouhituFontOTF.load();
  document.fonts.add(KouzanMouhituFontOTF);

  const canvas = document.getElementById('ambition-canvas') as HTMLCanvasElement;
  const ambitionTextarea = document.getElementById('ambition') as HTMLTextAreaElement;
  const ambitionCanvasDownloadButton = document.getElementById('ambition-canvas-download') as HTMLButtonElement;
  const modeToggle = document.getElementById('mode-toggle') as HTMLInputElement;
  const shareButton = document.getElementById('ambition-share') as HTMLButtonElement;
  const tweetAmbitionForm = document.getElementById('tweet-ambition') as HTMLFormElement;

  const vertical = await loadImage('/images/vertical.jpg') as HTMLImageElement;
  const verticalFontSize = 16 * 13; // 13rem
  const horizon = await loadImage('/images/horizon.jpg') as HTMLImageElement;
  const horizonFontSize = 16 * 6; // 6rem

  const writeTextOnCanvas = new WriteTextOnCanvas({
    canvas: canvas,
    image: vertical,
    font: 'KouzanMouhituFontOTF',
    fontSize: verticalFontSize,
    fontColor: '#404040',
  });

  writeTextOnCanvas.write(ambitionTextarea.value);

  ambitionTextarea.addEventListener('input', (e) => {
    const target = e.target as HTMLTextAreaElement;
    writeTextOnCanvas.write(target.value);
  });

  ambitionCanvasDownloadButton.addEventListener('click', (e) => {
    e.preventDefault();
    e.stopPropagation();
    writeTextOnCanvas.toBlob((blob) => {
      const link = document.createElement('a');
      link.href = URL.createObjectURL(blob);
      link.download = '今年の抱負';
      link.click();
      URL.revokeObjectURL(link.href);
    });
  });

  modeToggle.addEventListener('input', (e) => {
    const target = e.target as HTMLInputElement;
    writeTextOnCanvas.changeMode(target.checked ? 'horizon' : 'vertical');
    writeTextOnCanvas.changeImage(target.checked ? horizon : vertical);
    writeTextOnCanvas.changeFontSize(target.checked ? horizonFontSize : verticalFontSize);
  });

  shareButton.addEventListener('click', async (e) => {
    e.preventDefault();
    e.stopPropagation();
    const response = await fetch('/api/ambitions', {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        'X-Requested-With': 'XMLHttpRequest',
      },
      body: JSON.stringify({
        'ambitionImage': writeTextOnCanvas.toDataURL(),
        'ambitionText': writeTextOnCanvas.text(),
      })
    });

    const data = await response.json() as CreateAmbitionResponse;
    const ambitionUrl = data.ambitionUrl;

    const url = new URL('https://twitter.com/intent/tweet');
    const urlSearchParams = new URLSearchParams(url.search);
    urlSearchParams.append('url', ambitionUrl + '\n');
    urlSearchParams.append('text', writeTextOnCanvas.text() + '\n\n');
    urlSearchParams.append('hashtags', ['今年の抱負\n', '書き初め\n', '今年の抱負メーカー'].join(','));

    const shareUrl = new URL(`${url.origin}${url.pathname}?${urlSearchParams}`);

    const link = document.createElement('a');
    link.href = shareUrl.href;
    link.click();
  });

  tweetAmbitionForm.addEventListener('submit', () => {
    const input = document.querySelector('form input[name="ambitionImage"]') as HTMLInputElement;
    input.value = writeTextOnCanvas.toDataURL();
  });
};

とまぁ、こんな感じ。

ここから各部品、処理について詳しい解説を書いていきます。

✍ 流用しやすいようにクラスベースで書いてます。

冒頭にも書きましたがクラスで書いてます。

関数でも良いかな?とは思いましたが、オブジェクトを操作している感を出すためにある程度処理をまとめてます。

この例で利用している WEB フォントは 衡山毛筆フォント です。

初期化に必要な引数

copied.type CanvasWriteProps = {
    // document.getElementById('canvas の id')
    canvas: HTMLCanvasElement
    // document.getElementById('img の id') ただしここでは <img> 以外の方法で取得している。後述。
    image: HTMLImageElement
    font: string
    fontSize: number
    fontColor: string
}
引数内容
canvasCanvas の HTMLElement を指定します。
imageImage の HTMLElement を指定します。
font描画する font-family を指定します。
fontSize描画するフォントサイズを指定します。
fontColor描画するフォントカラーをしてします。

この引数をクラスのコンストラクタに渡します。

初期化

copied.// フォントの読み込み
const KouzanMouhituFontOTF = new FontFace('KouzanMouhituFontOTF', 'url(../fonts/KouzanMouhituFontOTF.otf)');
await KouzanMouhituFontOTF.load();
document.fonts.add(KouzanMouhituFontOTF);

// 引数に必要な値の取得
const canvas = document.getElementById('ambition-canvas') as HTMLCanvasElement;
const vertical = await loadImage('/images/vertical.jpg') as HTMLImageElement;
const verticalFontSize = 16 * 13; // 13rem

// 初期化
const writeTextOnCanvas = new WriteTextOnCanvas({
  canvas: canvas,
  image: vertical,
  font: 'KouzanMouhituFontOTF',
  fontSize: verticalFontSize,
  fontColor: '#404040',
});

コンストラクタでプロパティに必要な変数をセット。

copied.export default class WriteTextOnCanvas {
  private props: CanvasWriteProps;
  private context: CanvasRenderingContext2D;
  private vertical = true;
  private maxCharsPerLine: number;
  private value: string;

  constructor(props: CanvasWriteProps) {
    this.props = props;
    this.context = this.props.canvas.getContext('2d');
    this.initialize();
  }

渡した引数から context を取得しプロパティに維持。

copied.this.context = this.props.canvas.getContext('2d');

🧑‍🔧 Canvas の設定をしたい

一番はじめに行いたい設定です。

copied.export default class WriteTextOnCanvas {
  // 省略
  initialize() {
    this.props.canvas.width = this.props.image.width;
    this.props.canvas.height = this.props.image.height;
    // 省略
  }	
  // 省略
}

Canvas の縦横長を決めます。 この値はいわゆる解像度でもあるので、しっかり設定しましょう。

ここでは画像の大きさをそのまま解像度にしています。画像が大きければ大きいほど Canvas も大きくなるということです。

ちなみに、このままでは大きすぎて画面を埋めてしまうので、画面に描画する大きさ(見栄え、スタイル)は CSS で操作します。

 e.g.)copied.<!-- width, height は解像度-->
<!-- style="width: 200px; height: 200px;" は見栄え、スタイル(どう見えるか) -->
<canvas width="100" height="100" style="width: 200px; height: 200px;"></canvas>

🌅 画像ファイルをロードしたい

画像ファイルをロードし、Canvas に描画します。そのために「部品」で書いたこのコードを利用します。

loadImage.ts 画像をファイルパスからロードする関数。

copied.export default function loadImage (src: string) {
  return new Promise((resolve, reject) => {
    const image = new Image();
    image.src = src;
    image.onload = () => resolve(image);
    image.onerror = (e) => reject(e);
  });
}

Image API を利用して img を作成します。

画像を読み込むのは非同期処理になっているので、async / await に対応できるように Promise でラップしています。

copied.const vertical = await loadImage('/images/vertical.jpg') as HTMLImageElement;
const horizon = await loadImage('/images/horizon.jpg') as HTMLImageElement;

普通に同期的に使いたい場合はこのようにすればOK。

copied.export default class WriteTextOnCanvas {
  // 省略
  initialize() {
    // 省略
    this.context.drawImage(this.props.image, 0, 0);
    // 省略
  }
  // 省略
}

これで読み込んだ画像を Canvas の背景に描画し、その上から文字を乗っけることで合成します。

🔤 文字の設定をしたい

まず基本的なフォントの設定から

フォントは CSS でロードするか、JavaScript でロードするかしておけばそのまま font-family が利用できます。

ただ、 CSS でロードするとロード時間中 Canvas に反映されないので、 JavaScript でロードすることをおすすめします。

JavaScript でフォントの読み込み

copied.// フォントの読み込み
const KouzanMouhituFontOTF = new FontFace('KouzanMouhituFontOTF', 'url(../fonts/KouzanMouhituFontOTF.otf)');
await KouzanMouhituFontOTF.load();
document.fonts.add(KouzanMouhituFontOTF);

フォントを読み込んだら、諸々設定します。

フォント、色、サイズ設定

copied.export default class WriteTextOnCanvas {
  // 省略
  initialize() {
    // 省略
    this.context.font = this.props.fontSize + 'px ' + this.props.font;
    this.context.fillStyle = this.props.fontColor;
    // 省略
  }
  // 省略
}

フォントの設定は以上。

✍ 文字を書きたい

では、肝心の文字を描画する処理です。

Canvas は文字を描画するとき、改行や横文字、縦文字などをよしなに処理してくれませんので自作します。

現在のモードを格納するプロパティを用意する

現在の描画モードが縦横どちらなのかを格納するプロパティを用意します。これは単純に bool で表現してあげます。

copied.export default class WriteTextOnCanvas {
  private vertical = true;
  // 省略
  changeMode(mode: string) {
    this.vertical = mode === 'vertical';
    this.write(this.value);
  }
  // 省略
}

引数が文字列なのは縦横以外にもなにかモードがあるかもしれないな。と思ったからです。

まぁ、普通に bool を渡すように修正してもいいでしょう。直すのが面倒なのでこのまま進めます。大事なのは vertical には bool が入るということ。

デフォルトは true 、つまり縦書きモードです。 changeMode で横書きと切り替えられるよう、API を用意しています。 vertical を変更したら initialize で設定を初期化していますね。

横文字と縦文字を表現するために

文字列を縦に向けたり横に向けたりする処理は API として用意されていません。

自作するにしても、縦のときの描画と、横のときの描画の差をできるだけ大きくしたくありません。

そのために必要なことは

  • 縦横問わず、一行に何文字入るかを格納する変数と計算する処理を用意する
  • 一行に何文字入るかを元に、文字列を配列で表現する

ことです。

縦横問わず、一行に何文字入るかを格納する変数と計算する処理を用意する

まずは簡単な方。

copied.export default class WriteTextOnCanvas {
  // 省略
  initialize() {
    // 省略
    this.maxCharsPerLine = this.vertical
      ? Math.ceil((this.props.image.height - this.props.fontSize * 2) / this.props.fontSize)
      : Math.ceil((this.props.image.width - this.props.fontSize) / this.props.fontSize);
    // 省略
  }
  // 省略
}

maxCharsPerLine が「一行に何文字入るかを格納する変数」です。

計算する処理は vertical のモードによって変わっています。

文字を描画する対象の行幅 / 文字サイズ = 一行に何文字入るか です。

縦横の違いは分子を height にするかwidth にするかだけですが、 Canvas の仕様で

  • 縦の場合は 2 文字分
  • 横の場合は 1 文字分

引いてから計算しています。

これは好みの問題ではなく、ほとんど必須です。やってみればわかる。

一行に何文字入るかを元に、文字列を配列で表現する

このあたりは write に記載されている処理を追ってみましょう。

copied.export default class WriteTextOnCanvas {
  // 省略
  write(value: string) {
    this.value = value;
    this.initialize();

    const ambitions = value
      // 行ごとに文字列を分割
      .split('\n')
      // 文字列を文字の配列に分割
      .map(value => [...value])
      // 最大文字数分で更に配列を分割
      .map(ambition => {
        if (ambition.length > this.maxCharsPerLine) {
          return sliceByNumber(ambition, this.maxCharsPerLine);
        }
        return [ambition];
      })
      // 二層目の配列をフラットに修正。
      .flat(1);

    // 省略
  }
}

一つずつ追っていきます。

 e.g.)copied.このような文字列が
あったと
しましょう

仮に maxCharsPerLine は 5 文字とします。

まず、

copied.const ambitions = value
  .split('\n')

で、こうなります。

 e.g.)copied.[
  'このような文字列が',
  'あったと',
  'しましょう',
]

それが、

copied.const ambitions = value
  .split('\n')
  .map(value => [...value])

で、こうなります。

 e.g.)copied.[
  'こ', 'の', 'よ', 'う', 'な', '文', '字', '列', 'が',
  'あ', 'っ', 'た', 'と',
  'し', 'ま', 'し', 'ょ', 'う',
]

要素それぞれの配列長が maxCharsPerLine を超えていたら sliceByNumber を利用して、分割します。

copied.// `sliceByNumber.ts` 配列を渡した数値分ずつに分割する関数。
export default function sliceByNumber(array, number) {
  const length = Math.ceil(array.length / number);
  return new Array(length)
    .fill(0)
    .map((_, i) => array.slice(i * number, (i + 1) * number));
}

const ambitions = value
  .split('\n')
  .map(value => [...value])
  .map(ambition => {
    if (ambition.length > this.maxCharsPerLine) {
      return sliceByNumber(ambition, this.maxCharsPerLine);
    }
    // 次の処理のために、最大文字数を超えてなくても一つの配列として用意しておく。
    return [ambition];
  })

で、こうなります。

 e.g.)copied.[
  [
    ['こ', 'の', 'よ', 'う', 'な'],
    ['文', '字', '列', 'が'],
  ],
  [
    ['あ', 'っ', 'た', 'と'],
  ],
  [
    ['し', 'ま', 'し', 'ょ', 'う'],
  ]
]

これではループで回しにくいので、第二階層を flat します。

copied.const ambitions = value
  .split('\n')
  .map(value => [...value])
  .map(ambition => {
    if (ambition.length > this.maxCharsPerLine) {
      return sliceByNumber(ambition, this.maxCharsPerLine);
    }
    return [ambition];
  })
  .flat(1);

で、結果がこちら。

 e.g.)copied.[
  ['こ', 'の', 'よ', 'う', 'な'],
  ['文', '字', '列', 'が'],
  ['あ', 'っ', 'た', 'と'],
  ['し', 'ま', 'し', 'ょ', 'う'],
]

縦横問わず ループが回しやすくなりました。

こうすることで、ここからは縦横それぞれでこの配列をどのように描画するかを考えれば良さそうです。

文字描画の開始位置の指定する

ちょっと順番が前後しますが、細かい重要なところです。

文字の開始位置は

  • 縦の場合は右上から
  • 横の場合は左上から

スタートです。

copied.export default class WriteTextOnCanvas {
  // 省略
  initialize() {
    // 省略
    this.vertical
      ? this.context.translate(this.props.fontSize * (this.maxCharsPerLine - 1), this.props.fontSize * 1.5)
      : this.context.translate(this.props.fontSize * 0.5, 0);
  }
  // 省略
}

この設定は縦横それぞれ 1.5 文字分ずらしている処理です。

横の場合は簡単に表現できるんですが、縦の場合はちょっとややこしい。

この辺は好みの問題なので各自調整すればOK。

文字を描画する

最後にこれまで用意された設定と配列を利用して文字を描画していきます。

copied.export default class WriteTextOnCanvas {
  write(value: string) {
    // 値を保持
    this.value = value;
    // 現在の設定で初期化
    this.initialize();

    // 行ごとにループ
    ambitions.forEach(rows => {
      this.vertical
        // 縦書きの場合は行が進むごとに文字サイズ分だけ`左`(マイナス方向)にずらす。
        ? this.context.translate(-this.props.fontSize, 0)
        // 横書きの場合は行が進むごとに文字サイズ分だけ`下`(プラス方向)にずらす。
        : this.context.translate(0, this.props.fontSize);

      // 一気に行頭に戻るために、現状のポジション(行頭位置)を保存
      this.context.save();

      // 文字ごとにループ
      rows.forEach((value: string) => {
        if (this.vertical) {
          // 縦書きモードの場合、
          if (value.search(new RegExp(/[ー〜\-~「」[\]()()<><>{}{};:;:||==//__]/, 'g')) !== -1) {
            //特定の文字は 90 度傾ける。
            this.context.rotate((90 * Math.PI / 180));
            this.context.translate(-this.props.fontSize, 0);
            this.context.fillText(value, 0, 0);
            // 設定を戻す。
            this.context.rotate(-1 * (90 * Math.PI / 180));
            this.context.translate(0, this.props.fontSize);
          } else if (value.search(new RegExp(/[。、.,]/, 'g')) !== -1) {
            // 傾ける必要のない文字は座標移動で対応する。
            this.context.fillText(value, this.props.fontSize * 0.7, -this.props.fontSize * 0.7);
          } else {
            // それ以外はそのまま。
            this.context.fillText(value, 0, 0);
          }
        } else {
          // 横書きはそのまま。
          this.context.fillText(value, 0, 0);
        }

        this.vertical
          // 縦書きの場合は文字が進むごとに文字サイズ分だけ`下`にずらす。
          ? this.context.translate(0, this.props.fontSize)
          // 横書きの場合は文字が進むごとに文字サイズ分だけ`左`にずらす。
          : this.context.translate(this.props.fontSize, 0);
      });

      // 保持していたポジション(行頭位置)に戻る。
      this.context.restore();
    });
  }
}

解説はコード内にコメントで書きました。

工夫したのは

copied.// 一気に行頭に戻るために、現状のポジション(行頭位置)を保存
this.context.save();

// 省略

// 保持していたポジション(行頭位置)に戻る。
this.context.restore();

この辺。この関数を挟むことで、考え方がスッキリしているはずです。

しかし、

 特定文字の縦書き対応copied.if (value.search(new RegExp(/[ー〜\-~「」[\]()()<><>{}{};:;:||==//__]/, 'g')) !== -1) {
  // 省略
}

特定の文字を縦書きに対応する方法は苦難しました。

CSS の縦書きモード

copied.writing-mode: vertical-rl; 

が使えると良いのですが、 Canvas にはありませんでした。まぁ、 HTML なので当然といえば当然。

🛠 詳しい使い方

コードの詳しい解説ができたので最後に詳しい使い方。

文字列を書き込みたい

copied.// 初期化
const writeTextOnCanvas = new WriteTextOnCanvas({
  // 省略
});

// 適当な `id="ambition"` を持つ HTML を用意しておく。ここではテキストエリアです。
const ambitionTextarea = document.getElementById('ambition') as HTMLTextAreaElement;

// デフォルト値があるときとか。
writeTextOnCanvas.write('デフォルト値');

// テキストフォームやテキストエリアの変更を検知して書き込むとか。
ambitionTextarea.addEventListener('input', (e) => {
  const target = e.target as HTMLTextAreaElement;
  writeTextOnCanvas.write(target.value);
});

上記はあくまでも例です。

copied.writeTextOnCanvas.write('デフォルト値');

普通になにかのイベントを検知するなりして、 write を呼び出してください。

背景画像を切り替えたい

copied.// 初期化
const writeTextOnCanvas = new WriteTextOnCanvas({
  // 省略
});

// 縦書き用の画像に変えるとか
const vertical = await loadImage('/images/vertical.jpg') as HTMLImageElement;
writeTextOnCanvas.changeImage(target.checked ? horizon : vertical);

// 横書き用の画像に変えるとか
const horizon = await loadImage('/images/horizon.jpg') as HTMLImageElement;
writeTextOnCanvas.changeImage(target.checked ? horizon : vertical);

// 適当な `id="mode-toggle"` を持つ HTML を用意しておく。ここではチェックボックスです。
const modeToggle = document.getElementById('mode-toggle') as HTMLInputElement;

// 変更検知してモードを変えるとか。
modeToggle.addEventListener('input', (e) => {
  const target = e.target as HTMLInputElement;
  writeTextOnCanvas.changeImage(target.checked ? horizon : vertical);
});

上記はあくまでも例です。

copied.writeTextOnCanvas.changeImage(target.checked ? horizon : vertical);

普通になにかのイベントを検知するなりして、 changeImage を変更してください。

渡すのは HTMLImageElement であることに注意してください。 loadimage で取得できるやつです。

フォントサイズを変えたい

copied.// 初期化
const writeTextOnCanvas = new WriteTextOnCanvas({
  // 省略
});
writeTextOnCanvas.changeFontSize(32);

縦書きモード、横書きモードを切り替えたい

copied.// 初期化
const writeTextOnCanvas = new WriteTextOnCanvas({
  // 省略
});

// 縦書きモードに変えるとか
writeTextOnCanvas.changeMode('vertical');

// 横書きモードに変えるとか
writeTextOnCanvas.changeMode('horizon');

// 適当な `id="mode-toggle"` を持つ HTML を用意しておく。ここではチェックボックスです。
const modeToggle = document.getElementById('mode-toggle') as HTMLInputElement;

// 変更検知してモードを変えるとか色々。
modeToggle.addEventListener('input', (e) => {
  const target = e.target as HTMLInputElement;
  writeTextOnCanvas.changeMode(target.checked ? 'horizon' : 'vertical');
});

✍ 最後に

今回は久しぶりにガッツリとした記事を書いてみました。

内容としてはネット上に載っているものをまとめただけだけど、自分なりの設計思想を付加して書いてみたので差別化はできているはず。

ライブラリ化も考えたけど流石に機能が少なすぎた。GitHub に public で置くくらいのことは考えてみようかな。

冒頭にも書いたけど、どんな感じで動くかのデモは

実際にアプリ使ってみてね(宣伝)。

🗒️ 参考