HTMLCanvasElementでWebフォントをスムーズに適用する方法

## はじめに
HTMLCanvasElement（以下canvas）でWebフォントを動的に切り替えようとした際、情報が少なく苦労したため、備忘録としてQiita記事にまとめます。

最初から指定のフォントで描画すれば問題ないのですが、canvas上でフォントの選択肢を増やしたく、動的に切り替える方法を模索しました。

## 前提
* WebフォントはGoogle Fonts APIを利用
* Reactを使用したWebアプリで開発

## 通常のWebフォント利用方法

例えばRampart One,DotGothic16,Dela Gothic Oneの通常字と太字（400,700）を利用したい場合、CSSで以下のようにインポートできます。

```index.css
@import url("https://fonts.googleapis.com/css2?family=Rampart+One:wght@400;700&display=swap");
@import url("https://fonts.googleapis.com/css2?family=DotGothic16:wght@400;700&display=swap");
@import url("https://fonts.googleapis.com/css2?family=Dela+Gothic+One:wght@400;700&display=swap");

```

今回は以下のような一般的なcanvasからスタートします。
即時反映に課題があり、選択肢を行ったり来たりしていると、2回目選択時から読み込まれるようになります。


```canvas.tsx
import React, { useRef, useEffect, useState } from "react";
import "./index.css";

const CanvasText: React.FC = () => {
  const canvasRef = useRef<HTMLCanvasElement>(null);
  const [fontFamily, setFontFamily] = useState("Dela Gothic One");

  useEffect(() => {
    const canvas = canvasRef.current;
    if (!canvas) return;
    const ctx = canvas.getContext("2d");
    if (!ctx) return;

    // Canvasのサイズ
    canvas.width = 300;
    canvas.height = 150;

    // 背景
    ctx.fillStyle = "#f0f0f0";
    ctx.fillRect(0, 0, canvas.width, canvas.height);

    // 文字の設定
    ctx.font = `24px ${fontFamily}`;
    ctx.fillStyle = "#333";
    ctx.textAlign = "center";
    ctx.textBaseline = "middle";

    // 文字を描画
    ctx.fillText("abc ひらがな カタカナ 漢字", canvas.width / 2, canvas.height / 2);
  }, [fontFamily]);

  return (
    <div>
      <canvas ref={canvasRef} />
      <div>
        <label>
          Font Family:
          <select value={fontFamily} onChange={(e) => setFontFamily(e.target.value)}>
            <option value="Dela Gothic One">Dela Gothic One</option>
            <option value="DotGothic16">DotGothic16</option>
            <option value="Rampart One">Rampart One</option>
          </select>
        </label>
      </div>
    </div>
  );
};

export default CanvasText;
```

## ①document.fonts.loadでフォントをロードする

まずは文字描画前にdocument.fonts.loadすればよいのでは？と考え、以下のように await してみました。

試した結果：
* 初期表示はデフォルトフォントのまま
* abc・ひらがな・カタカナは1回目選択時から正しく表示
* 漢字は2回目以降しか反映されない

```canvas.tsx
const CanvasText: React.FC = () => {
  const canvasRef = useRef<HTMLCanvasElement>(null);
  const [fontFamily, setFontFamily] = useState("Dela Gothic One");

  const loadText = useCallback(async () => {
    await document.fonts.load(`24px ${fontFamily}`);
  }, [fontFamily]);

  useEffect(() => {
    const drawText = async () => {
      await loadText();
      ...
    };

    drawText();
  }, [fontFamily, loadText]);

...
```
調べてみると、
* document.fonts.load は フォントのロードを開始する処理 であり、使用可能になるまでの待機処理ではない
* フォントが完全に読み込まれる前に、canvasにデフォルトフォントで描画されてしまう

...ということなんですね。

## ②document.fonts.readyでフォントが利用可能になるまで待つ

document.fonts.ready を利用すると、[フォントのロードが完了するまで待機できそうだと見た](https://developer.mozilla.org/ja/docs/Web/API/Document/fonts)ので取り入れました

試した結果：

* Webフォントが適用されたりされなかったり不安定（特に漢字）

```canvas.tsx
const CanvasText: React.FC = () => {
  const canvasRef = useRef<HTMLCanvasElement>(null);
  const [fontFamily, setFontFamily] = useState("Dela Gothic One");

  const loadText = useCallback(async () => {
    await document.fonts.load(`24px ${fontFamily}`);
  }, [fontFamily]);

  useEffect(() => {
    const drawText = async () => {
      await loadText();
      await document.fonts.ready;
      ...
    };

    drawText();
  }, [fontFamily, loadText]);

...
```

## ③document.fonts.onloadingdoneでフォントがロードされたことを検知する

漢字の対応が遅れるのはフォントが使用可能になったことをトリガーに再描画すれば解決できるのでは？と考え、document.fonts.onloadingdone を活用しました。

試した結果：
* 漢字も自動的にロードされるようになりました！ 🎉
* ただし、若干のちらつきは発生

```canvas.tsx
const CanvasText: React.FC = () => {
  const canvasRef = useRef<HTMLCanvasElement>(null);
  const [fontFamily, setFontFamily] = useState("Dela Gothic One");
  const [fontsLoaded, setFontsLoaded] = useState(0);

  const loadText = useCallback(async () => {
    await document.fonts.load(`24px ${fontFamily}`);
  }, [fontFamily]);

  useEffect(() => {
    document.fonts.onloadingdone = () => {
      setFontsLoaded(fontsLoaded+1);
    }
  }, [fontsLoaded]);

  useEffect(() => {
    const drawText = async () => {
      await loadText();
      await document.fonts.ready;
      ...
    };

    drawText();
  }, [fontFamily, loadText, fontsLoaded]);

...
```
低速のネットワーク回線を使っている場合は多少気になるかもしれません。
ちらつきについては、フォントを手動で切り替える今回のケースでは許容範囲と判断しました！

## おまけ

webfontloader を使うと、CSS不要でWebフォントを動的にロードでき、処理も高速化されます。
便利なのでおすすめしますが、それでもフォントが使用可能となる前に描画されてしまう問題は発生しました。

## まとめ

最終的には以下のようになりました。

```canvas.tsx
import React, { useRef, useEffect, useState, useCallback } from "react";
import "./index.css";

const CanvasText: React.FC = () => {
  const canvasRef = useRef<HTMLCanvasElement>(null);
  const [fontFamily, setFontFamily] = useState("Dela Gothic One");
  const [fontsLoaded, setFontsLoaded] = useState(0);

  const loadText = useCallback(async () => {
    await document.fonts.load(`24px ${fontFamily}`);
  }, [fontFamily]);

  useEffect(() => {
    document.fonts.onloadingdone = () => {
      setFontsLoaded(fontsLoaded+1);
    }
  }, [fontsLoaded]);

  useEffect(() => {
    const drawText = async () => {
      await loadText();
      await document.fonts.ready;
      const canvas = canvasRef.current;
      if (!canvas) return;
      const ctx = canvas.getContext("2d");
      if (!ctx) return;

      // Canvasのサイズ
      canvas.width = 600;
      canvas.height = 300;

      // 背景
      ctx.fillStyle = "#f0f0f0";
      ctx.fillRect(0, 0, canvas.width, canvas.height);

      // 文字の設定
      ctx.font = `24px "${fontFamily}", sans-serif`;
      ctx.fillStyle = "#333";
      ctx.textAlign = "center";
      ctx.textBaseline = "middle";

      // 文字を描画
      ctx.fillText("abc ひらがな カタカナ 漢字", canvas.width / 2, canvas.height / 2);
    };

    drawText();
  }, [fontFamily, loadText, fontsLoaded]);

  return (
    <div>
      <canvas ref={canvasRef} />
      <div>
        <label>
          Font Family:
          <select value={fontFamily} onChange={(e) => setFontFamily(e.target.value)}>
            <option value="Dela Gothic One">Dela Gothic One</option>
            <option value="DotGothic16">DotGothic16</option>
            <option value="Rampart One">Rampart One</option>
          </select>
        </label>
      </div>
    </div>
  );
};

export default CanvasText;
```

React + Canvas でWebフォントを扱う際の参考になれば幸いです！
もしさらに良い方法ご存知の方がいらっしゃればぜひご意見ください。