canvas でシェア用の画像やバナーを作っていると、こんな絵に出会うことがあります。英数字は指定した書体で出ているのに、漢字だけが別の書体になっている。エラーは出ていません。フォントの読み込みもきちんと await しています。私も同じような処理を書くことがあるので、テストページを作って確かめてみました。先に書いておくと、フォントを待つときに「これから描く文字」を渡していなかったことが、漢字だけ書体が変わる条件でした。
試した環境は Apple M4 の Mac(macOS)で、ブラウザーは Chromium 149、Chrome 155、Firefox 151、WebKit 26.5(macOS)の 4 つです。スマホのブラウザー、Windows、Safari の正式版、Edge では試していません。もう一つ断っておくことがあります。測ったのは中国語用の Noto Serif SC で、描いた文字も簡体字の「秋季新品 全场八折」です。日本語のフォントは未計測です。下の手順は日本語フォントにもそのまま使えますが、同じ結果になるかどうかは未確認です。
第 2 引数なしではラテン用しか届かなかった
まず用語です。@font-face は「この名前のフォントは、このファイルから取ってくる」とブラウザーに伝える CSS の宣言です。unicode-range はそこに書き足す指定で、「このファイルが受け持つのはこの範囲の文字」という意味になります。漢字を含むフォントはファイルが大きいので、文字の範囲ごとにファイルを分けておき、必要になった分だけ取得させる使い方をします。今回は同じファミリー名で 2 つ宣言しました。ラテン用が 52,376 バイト、漢字用が 986,556 バイトです。サブセットは fontTools 4.60.2 で作りました。
@font-face { font-family: "BannerSerif"; src: url("banner-latin.woff2"); unicode-range: U+0020-007E; }
@font-face { font-family: "BannerSerif"; src: url("banner-han.woff2"); unicode-range: U+4E00-9FFF; }
canvas の側では document.fonts.load() を使います。フォント指定の文字列を渡すと、そのフォントの取得が終わるまで待てる API です。最初は await document.fonts.load('48px "BannerSerif", sans-serif') とだけ書いて、そのあと fillText(canvas に文字を描くメソッド)で中国語の 1 行を描きました。4 つのブラウザーとも、漢字は既定フォントで描かれました。ここでいう既定フォントは、font の文字列で後ろに書いておいた sans-serif のことです。文字が化けたり消えたりしたわけではなく、サンセリフの書体で普通に読めます。気づきにくいわけです。同じ状態で英語の「Autumn Sale 2026」を描くと、こちらは指定したフォントで出ました。load() が返ってきた時点で届いていたのはラテン用のファイルだけで、返り値の配列も 1 件でした。エラーは投げられていません。
判定は目視ではなく、描いたあとの canvas の画素から計算した値を、見本と比べる方法にしました。見本は 2 枚あります。指定フォントで描いたものと、既定フォントで描いたものです。中国語の 1 行はどちらとも一致しませんでした。漢字は既定フォントなのに、間に入れた半角スペースだけはラテン用のファイルで描かれていたからです。見た目ではまず分からない差で、これは値を比べて初めて気づきました。
上の行はフォント指定だけを渡して待ったあとの描画で、漢字がサンセリフの書体になっています。下の行は次の節の書き方に変えたもので、行全体がセリフのある Noto Serif SC です。「秋」や「折」の払いの形を見比べると違いが分かります。
描く文字を渡して待つと漢字用も揃っていた
document.fonts.load() には 2 つ目の引数があり、描く予定の文字列を渡せます。ここに「秋季新品 全场八折」を渡して待つと、返ってきた配列は 2 件になり、漢字用のファイルも届いていました。そのあと同じ 1 行を描くと、指定フォントの見本と一致します。4 つとも同じでした。漢字用のファイルがいつ要求されたのかも、リソースの取得時刻で見ています。リクエストが出ていたのは、前の節で中国語を最初に描いた時点でした。あの描画は既定フォントになりましたが、漢字用の取得はそこで始まっていたことになります。文字を渡した load() がしていたのは、それが届くまで待つことでした。引数を省いて待つと、なぜラテン用が届いた時点で返ってくるのかは、調べていません。仕様書を読んで裏を取ったわけでもないので、ここに書いているのは見えた挙動だけです。
返り値の長さと check で描く前に確かめる
待ち方を直したあと、本当に揃ったかを描く前に確かめる手順も決めました。見るのは 2 か所です。
const bannerFont = '48px "BannerSerif", sans-serif';
const headline = '秋季新品 全场八折';
const faces = await document.fonts.load(bannerFont, headline);
console.log(faces.length); // 今回の 2 分割なら 2
console.log(document.fonts.check(bannerFont, headline)); // 漢字用が揃っていれば true
ctx.font = bannerFont;
ctx.fillText(headline, 16, 60);
1 つ目は load() の返り値です。待った結果として配列が返り、文字を渡さなかったときは長さ 1、渡したときは 2 でした。分割した数と合っているかをここで見ます。2 つ目の document.fonts.check() は、「このフォント指定でこの文字を描く準備ができているか」を true か false で答える API です。こちらも文字を渡すかどうかで答えが変わりました。文字を渡さずに待って中国語を描いた直後の状態で、check(bannerFont) は true を返し、check(bannerFont, headline) は false を返しています。文字を渡さない check の true は、漢字については何も保証していませんでした。
check だけに頼らない理由がもう一つあります。宣言していない名前、たとえば 48px "NoSuchFont9" を渡しても、4 つのブラウザーは true を返しました。この結果からすると、ファミリー名を打ち間違えたときも true が返りそうです。打ち間違いそのものは試していないので、これは推測です。そのため私は返り値の長さも一緒に見ることにしています。打ち間違えた名前で load() の配列が何件になるかも試していません。ここは次に確かめるつもりです。
既製のツールがこの「待ち」をどう扱っているかも、一つだけ見ました。私が使ったのは、ブラウザーで画像を編集できる ImgIng(https://imging.ai/ )のデザイン機能で、PC のブラウザーから開いています。テキストレイヤーのフォント一覧には、選んだときに初めてダウンロードされるものがあり、初回のダウンロード量が画面に書かれていました。韓国語フォントの応答をわざと遅らせ、選んでから約 0.2 秒で書き出しのボタンを押しています。書き出されたファイルは、フォントの応答が届いたあとに作られました。この PNG は、フォントが揃うのを待ってから書き出したものとバイト単位で同じで、どちらも 39,765 バイトです。システムフォントのまま書き出した 39,854 バイトの PNG とは別物でした。見たのは Chromium 149 でこの流れ一つ、フォントも一種類だけです。内部の実装は見ていません。
描いてあるのは韓国語の「가을 신상품 세일」です。上と下で線の太さと字の形が違います。下は選んだ直後に押した書き出しですが、韓国語フォントの字形になっています。
いま自分のコードでは、fillText に渡す文字列と同じものを load() の第 2 引数に渡しています。文言が入力で変わる画面なら、描くたびに渡し直すことになります。残っている宿題は 2 つです。日本語フォントでの確認と、ひらがな・カタカナ・漢字のように 3 つ以上へ分けた構成での確認です。試したのは 2 分割だけなので、分割が増えたときに返り値の長さがどう数えられるかはまだ見ていません。
テストに使ったフォントは Noto Serif SC(思源宋体)の Regular で、ライセンスは SIL OFL 1.1 です。

