Google fonts (`next/font/google`) が html canvas で表示できない時がある

# はじめに

Next.jsでフォントを読み込むパッケージ `next/font` を使って、canvasにフォントを描画したい。
特に、文字を入力したときにリアルタイムで変わるとより嬉しい。

しかし、たまにフォントが反映されない。なぜだ〜。


```package.json
...
"@next/font": "^14.2.15",
"next": "15.0.4",
"react": "^19.0.0",
...
```

# 問題

1. canvasはフォントが読み込まれてから生成しないと、文字が反映されない。
1. `await document.fonts.ready` しても反映されない時がある……
```ts
// これで反映されない場合がある
const drawText = async (
  canvasRef: RefObject<HTMLCanvasElement>,
  fontName: string,
  text: string,
) => {
  await document.fonts.ready; // フォントの読み込みを待つ

  const canvas = canvasRef.current; // canvas用意
  const ctx = canvas.getContext('2d');
  if (!ctx) return;
  clearCanvas(ctx, canvas);

  ctx.font = `38px ${fontName}`; // フォント指定
  ctx.fillText(text, 10, 10);
  ctx.restore();
}
```

## `react` と `next/font` 利用の状態

Google fontsの利用も問題ないはずなんだけどなぁ

```tsx
// Google fonts を利用
import { Hina_Mincho } from 'next/font/google';

const HINA_MINCHO = Hina_Mincho({
  subsets: ['latin'],
  weight: '400',
  display: 'swap',
});

// フォントネームをテキスト化
const HINA_MINCHO_NAME = HINA_MINCHO.style.fontFamily.split(',')[0]

// Canvasコンポーネント
const Canvas = ({ text }: Props) => {
  const canvasRef = React.useRef<HTMLCanvasElement>(null);

  // textが変更されたときに、先ほどの関数を実行
  useEffect(() => {
    if (!canvasRef.current) return;
    drawText(canvasRef, HINA_MINCHO_NAME, text); 
  }, [text]);

  return (
    <canvas
      ref={canvasRef}
      id="canvas"
      width="400"
      height="400"
    ></canvas>
  );
};

const MemoizedCanvas = React.memo(Canvas); // メモ化
export { MemoizedCanvas as Canvas }; // メモ化したものをCanvasという名前でexport
```


# 主たる原因

### ざっくり解説

`next/font/google` で読み込んでくるフォントデータは、`unicode-range`の設定により、ページ内のフォントが当てられたテキストの分だけ読み込まれるようになっている。
canvasの描画前に "ページ内でフォントが当てられていない" ため、読み込まれない。



### 詳細解説

`next/font/google`で、DOMのテキストを表示してみる。

```jsx
<div className={hinaMincho.className}></div>
```

このとき、Chromeの検証ツールで見てみると、`<body>`に以下のようなリンクが生成される。

```html
<link rel="preload" href="/_next/static/media/xxx.p.woff2" as="font" crossorigin="" type="font/woff2">
~~~
<link rel="stylesheet" href="/_next/static/css/xxx.css" data-precedence="next">
```

`woff2`はフォントの拡張子。`preload`によって読み込まれている。
`preload`はリソースのダウンロードを優先し、キャッシュしておくことで、cssなどで必要になったタイミングですぐに使用できるようにする技術。

では次に、その下のCSSの中身を見てみる。
検証ツールでCSSリンクを右クリックして「Open in new tab」してみる。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/180775/02f30b17-b43d-b114-eff8-15a7925834ef.png)

うわぁ

`@font-face{ ...` から始まり、文字コード情報が並んでいる。
これが、`unicode-range`を指定するCSSファイル。

以下のような構造になっている。

```scss
@font-face {
  font-family: Sawarabi Mincho;
  src: url('~~~.woff2') format('woff2');
  unicode-range: u+9e8b-9e8c, u+9e8e-9e8f; /* ASCII文字 */
}
@font-face { 
/* 以下同じようなものが続く */
```

CSSで `unicode-range` を指定することで、フォントの一部（特定の文字コード範囲）だけを使用できる。この指定により、ブラウザは必要な部分のみを読み込むことができる。

ブラウザはページ内のテキストを解析し、`unicode-range` に応じて必要なフォントデータをリクエストする。

canvas描画前は、ページ内のテキストがない状態。「必要なフォントデータはない」と判断されているっぽい。

:::note warn
canvas 要素のフォント指定は、ブラウザのフォントレンダリングエンジンを利用するため、基本的にはブラウザのフォント解析の仕組みが適用されるらしいです。だったら、ブラウザのテキスト解析にも引っかかるはずですが……。正直、各ブラウザの詳細な実装を見てみないとなんとも言い難いので、本当のことはわかりません。
:::

# 解決策

DOM に canvas で表示したい文字と同じ文字を表示して、フォントスタイルを当てておく。
こうすれば、DOM側で確実にフォントが読み込まれる。

```tsx
// Canvasコンポーネント
const Canvas = ({ text }: Props) => {
  const canvasRef = React.useRef<HTMLCanvasElement>(null);

  // textが変更されたときに、先ほどの関数を実行
  useEffect(() => {
    if (!canvasRef.current) return;
    drawText(canvasRef, HINA_MINCHO_NAME, text); 
  }, [text]);

  return (
    <>
      <div className="pre-draw-font">
        <span className={hinaMincho.className}>{text}</span>
      </div>
      <canvas
        ref={canvasRef}
        id="canvas"
        width="400"
        height="400"
      ></canvas>
    </>
  );
};
```

フォントスタイルを当てたDOMは、ユーザーから見えないように消しておく。

```css
.pre-draw-font {
  position: fixed;  /* サイズ変更の影響を与えない */
  opacity: 0;  /* 透明にする */
  pointer-events: none; /* ユーザー操作の当たり判定を消す */
}
```

reactのDOM構造をキレイに保ちたい場合は、JavaScript側でDOMを生成するのもあり。

# 懸念

ただ、これでも、スマホではうまくいかないケースがあるらしい。`unicode-range`を指定しないで、フォントをドカッと全部読み込むのが最適解なのか〜？

誰か教えてくれ〜〜 ( ; ; )