はじめに
Next.jsでフォントを読み込むパッケージ next/font
を使って、canvasにフォントを描画したい。
特に、文字を入力したときにリアルタイムで変わるとより嬉しい。
しかし、たまにフォントが反映されない。なぜだ〜。
...
"@next/font": "^14.2.15",
"next": "15.0.4",
"react": "^19.0.0",
...
問題
- canvasはフォントが読み込まれてから生成しないと、文字が反映されない。
-
await document.fonts.ready
しても反映されない時がある……
// これで反映されない場合がある
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の利用も問題ないはずなんだけどなぁ
// 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のテキストを表示してみる。
<div className={hinaMincho.className}></div>
このとき、Chromeの検証ツールで見てみると、<body>
に以下のようなリンクが生成される。
<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」してみる。
うわぁ
@font-face{ ...
から始まり、文字コード情報が並んでいる。
これが、unicode-range
を指定するCSSファイル。
以下のような構造になっている。
@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描画前は、ページ内のテキストがない状態。「必要なフォントデータはない」と判断されているっぽい。
canvas 要素のフォント指定は、ブラウザのフォントレンダリングエンジンを利用するため、基本的にはブラウザのフォント解析の仕組みが適用されるらしいです。だったら、ブラウザのテキスト解析にも引っかかるはずですが……。正直、各ブラウザの詳細な実装を見てみないとなんとも言い難いので、本当のことはわかりません。
解決策
DOM に canvas で表示したい文字と同じ文字を表示して、フォントスタイルを当てておく。
こうすれば、DOM側で確実にフォントが読み込まれる。
// 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は、ユーザーから見えないように消しておく。
.pre-draw-font {
position: fixed; /* サイズ変更の影響を与えない */
opacity: 0; /* 透明にする */
pointer-events: none; /* ユーザー操作の当たり判定を消す */
}
reactのDOM構造をキレイに保ちたい場合は、JavaScript側でDOMを生成するのもあり。
懸念
ただ、これでも、スマホではうまくいかないケースがあるらしい。unicode-range
を指定しないで、フォントをドカッと全部読み込むのが最適解なのか〜?
誰か教えてくれ〜〜 ( ; ; )