こなさんみんばんわ。
ええと、今日は確か 4 月 31 日やんな?(現実逃避)

ところで、先日うちにある全 Apple 製品の OS アップデートを行ったんですが、Mac と iPhone には Image Playground が入ったのに iPad には入らないのなんでだろう…と思ったら、僕の第 3 世代 iPad Pro 12.9 インチは Apple Intelligence 非対応でした。かな C (´・_・`)

ていうか、この機種ももう発売開始は 7 年前、自分が買ったのも 6 年くらい前の話なんよなぁ。そりゃ古くもなるし Apple Intelligense も使えん訳だ。そろそろ新しいやつにしたいけど、残念ながらお金がないのでもうしばらく使うしかないな。ちなみに昨年末にも書いたけど、オレの用途であればもう Air 13 インチで十分だと思ってるので、次に買うならそうする予定…今のところは。涙

そんな話はさておき、iOS・iPadOS といえばどちらにも macOS と同じくヒラギノ角ゴシックが入ってる訳ですが、font-weight: normal に相当する W4 が入ったのは割と最近(iOS 18 以降)のことなので、例えばサイト側の CSS で本文表示用に normal400 を指定していても、ユーザーの iOS のバージョンによっては W5 や W3 で表示されてしまいます。

このこと自体は CSS の仕様で決められているとおりなので別にいいのですが、特定の条件下(あとで説明します)ではこれだと少し困ってしまう場面というのが実はあります。なので、ユーザーの iOS に入ってるヒラギノ角ゴシックのウエイトに合わせて font-weight の値を調整できないか? と思い、実際にそれ用のスクリプトを書いてみるなどしましたので、今回はその試行錯誤の内容を記しておこうと思います。

その「少し困ってしまう場面」とはどんな場面?

和文も欧文も全部ヒラギノ角ゴシック(以下、便宜上ヒラギノとする場合があります)で表示しているという Web サイトであれば、おそらくこれが大きな問題になることはほぼありません。せいぜい「あれ、このサイト OS アップデートしてから文字がちょっと太くなったな」と思われる程度ですかね。

困る場面というのは和欧混植をしていて、かつ欧文フォント側がバリアブルであるとか多ウエイト展開の場合です。例えばこのサイトは欧文フォントが Inter のバリアブル・フォント、和文フォントがヒラギノ角ゴシック1という指定ですが、仮に「ヒラギノの W4 や W5 は当然あるだろう」という前提で次のような font-weight 指定を書いたとします。

/* 本文のフォントには W4 を使うよ */
html {
  font-family: Inter, Hiragino Sans, sans-serif;
  font-weight: 400;
}
/* 小さな見出しは本文と差をつけるために若干太くしよう */
h5, h6 {
  font-size: 100%;
  font-weight: 500;
}

この場合、W4 も W5 も入っている iOS 18.x の環境であれば、当然ながら本文には Inter の 400 ウエイトとヒラギノ角ゴシックの W4 が、h5 および h6 見出しには Inter の 500 ウエイトとヒラギノの W5 が使われます。

さて、これが iOS 17.x 以前であればどうなるでしょう? この OS バージョンにはヒラギノの W4 が入っていませんので、和文の本文フォントにはウエイトの代替アルゴリズムにより W5 もしくは W32使われることになります。

しかし欧文フォントである Inter の方はどうかというと、こちらは CSS での指定通り 400 ウエイトが使用されます(というより、バリアブルなのでウエイトはどうにでもなります)。その結果、和文部分と欧文部分でウエイトが異なるという事象が発生してしまいます

さらに iOS 16.x 以前となると W5 も入ってませんので、h5h6 見出しには Inter の 500 ウエイトとヒラギノの W3 が使用されることになります。さすがに 200 ウエイトの差があると誰が見ても「あ、太さが違うな」と気が付くレベルではないでしょうか。

👇 実際に Noto Sans JP で試してみたやつ

See the Pen Untitled by Jeffrey Francesco (@jforg) on CodePen.

ユーザーの閲覧環境に合わせて font-weight を調整したい

この「ウエイト合わない問題」を回避するためには、ユーザーの閲覧環境に入ってるフォント(のウエイト)を判別し、それに合わせて CSS の font-weight を調整する必要があります。とりあえず一番影響がありそうなのは現行の v18 より古い iOS だけ3なので、対象のフォントはヒラギノ角ゴシックだけでいいでしょう4

あとはその仕組みをどうやって作るかですが、これに関しては昔どこかで読んだ「幅が 0 のフォントを使ってユーザーの環境にあるフォントを検出する」という手法を何となくですが覚えてましたので、それを使うことにします。流れとしては、

  1. 文字幅が 0 になるフォント A を用意する
  2. ページ内の可視範囲外に 1 文字だけのテキスト内容を持つ検出用の要素 B を固定配置する
  3. Bfont-family に目的のフォントと A のフォントを指定する
  4. B の要素幅 (offsetWidth) w を JavaScript で取得する
  5. w が 0 でなければ環境に目的のフォントあり / 0 であればなし

として、その結果に応じて色々やる…みたいな感じです。具体的な実装方法については全然覚えてませんが、その記事を読んだのはずいぶん前の話ですし、当時に比べればブラウザの実装も進化してるので、もし覚えてたとしても時代遅れの記述もあるなどで、そのままでは使えないような気がします。という訳で、最初から自力で書いてみることにしました。

最初に書いたコード(失敗作😭)

テスト段階なので、幅 0 のフォントについてはあとで考えるとして、とりあえず Adobe が公開しているオープンソース・フォントである Adobe Blank 2(以下 Blank フォント)のリポジトリにある CSS の中身をそのまま使っておくことにします。で、最初に書いたのが以下のようなものです。

<!-- `head` 要素内に -->
<style>
@font-face {
  font-family: AdobeBlank2;
  src: url("data:font/opentype;base64,T1RUTwAK…[中略]…AAAAAA==");
}
html {
  /* 混乱の元になる不要な外部リクエストが発生しないよう、システム・フォントのみを指定しています */
  font-family: Helvetica Neue, Hiragino Sans, sans-serif;
  font-weight: 400;

  &._no-w4 {
    font-weight: 300;
  }
}
</style>

<!-- `body` 開始タグ直後に -->
<div id="detector" style="position:fixed;top:-10em">a</div>
<script>
const root = document.documentElement
const detector = document.getElementById('detector')
const detectFont = (cls, fonts) => {
  detector.style.fontFamily = `${fonts}, AdobeBlank2`
  const width = detector.offsetWidth
  const prefix = width > 0 ? '_has' : '_no'
  root.classList.add(`${prefix}-${cls}`)
  return Boolean(width)
}
detectFont('hiragino', 'Hiragino Sans') && detectFont('w4', 'Hiragino Sans W4')
</script>

最初に目的のフォントの有無によって html 要素に class 名を与え分ける detectFont() という関数を定義しておき、それを使ってまずはヒラギノ角ゴシックそのものの有無をチェック。ある場合は次にヒラギノ角ゴシック W4 があるかを調べて、なかった場合は(_no-w4 という class 名が付与されるので)font-weight: 300 が適用される…という感じです。

それから Xcode の Simulator で iOS 17.5 の環境を作成、Safari を起動してテストしてみましたが、まぁ詳しい方であればお分かりかと思いますが、このコードはうまく機能しません。なぜかというと、スクリプトが動作しはじめる時点で Blank フォントがロードされているという保証はどこにもないからです。

画像: 先のコードを含む HTML ファイルの読み込みをタイムライン収録したもののスクリーンショット。スクリプトの動作 (A) は 10.91ms〜11.25ms あたりだが、フォントのロード (B) は 11.50ms〜15.25ms の間である

これは Safari の開発者ツールを使い、キャッシュを無視した状態でタイムライン収録したものです。detectFont() が動作してるのは図の A あたりですが、Blank フォントのロードが完了しているのはそれより後の B の部分。つまり、チェックが走る場面ではまだフォントが使用できる状態にないので、ヒラギノ角ゴシック W4 がない場合は Safari のデフォルト表示フォントにフォールバックされてしまい、幅が 0 にならないのでうまくいかないんですね。

僕はそういうブラウザの中のことについてはあまり詳しくないので、「Data URI で指定しておけばその場で解釈されてフォントが認識される」ものだと思ってましたが、違うみたいです。涙

CSS Font Loading API を使って書き直す

つまり、このスクリプトをちゃんと機能させるには、何らかの手法でフォントが読み込まれたことを検知して、読み込み完了後にスクリプトが動作するようにしないといけません。ひと昔前なら setTimeout() とか駆使してがんばって書いてたやつ…ですが、調べてみますと今では CSS フォント読み込み API という便利なものが、すべての主要ブラウザに実装されてるんだそうですよ。まぁ素敵💓

という訳で、先のコードの JavaScript 部分を次のように変更しました。

document.fonts.load('1em AdobeBlank2').then(
  // コールバック関数の引数(ロードが完了したフォントの配列が入る)は使わないならいらんのだろうけど、自分の書いたテストコードそのままコピペしたので…
  (ffs) => {
    const root = document.documentElement
    const detector = document.getElementById('detector')
    const detectFont = (cls, fonts) => {
      detector.style.fontFamily = `${fonts}, AdobeBlank2`
      const width = detector.offsetWidth
      const prefix = width > 0 ? '_has' : '_no'
      root.classList.add(`${prefix}-${cls}`)
      return Boolean(width)
    }
    detectFont('hiragino', 'Hiragino Sans') && detectFont('w4', 'Hiragino Sans W4')
  }
)

先ほどのコード全体が document.fonts.load().then(() => { … }) というので囲まれていますね。document.fonts.load() の部分は () 内に指定したフォント5を読み込ませるメソッドで、読み込みが完了したら解決する Promise を返します。よって then() 内のコードは必ず指定フォント(ここでは Blank フォント)のロードが終わった後に実行されることが保証されるので、先ほどのような失敗はなくなります。

画像: コード修正後のタイムライン収録スクリーンショット。フォントのロード (12.87ms〜14.94ms) 完了後に detectFont() 関数 (17.59ms〜18.04ms あたり) が実行されている

タイムライン収録の結果もご覧のように改善されましたし、Simulator 上の html 要素にもちゃんと _no-w4 class 属性が付与されるようになりました。フォントもちゃんと日本語とアルファベットのウエイトが揃っています。

画像: コード修正後の要素詳細(左)と、Simulator による iOS 17.5 の表示スクリーンショット。html 要素に `_no-w4` class 属性が付与され、フォントも太さが揃っているのが確認できる

フォント定義や検証用要素の挿入もスクリプト側から処理する

さて、とりあえずこのコードでも動作はしそうですが、ここまで来ると今度は HTML 内に @font-face によるフォント定義や検出用の div 要素がベタ書きになってるのが気になってきますよね。どちらとも JavaScript から扱って処理するようにすれば、汎用化などもできそうな気がします。

という訳で、ベタ書きしていた @font-face<div id="detector" …> を先の HTML コード内から削除して、JavaScript で処理するようにしてみます。

document.fonts.add(
  // `@font-face {}` に相当する部分
  new FontFace('AdobeBlank2', 'url("data:font/opentype;base64,T1RUTwAK…[中略]…AAAAAA==")')
)
document.fonts.load('1em AdobeBlank2').then(
  (ffs) => {
    const root = document.documentElement
    // `<div id="detector" …>`に相当する部分
    const detector = document.createElement('div')
    detector.style.position = 'fixed'
    detector.style.top = '-10em'
    detector.textContent = 'a'
    // これを `body` 要素の最初の子として挿入する
    document.body.insertAdjacentElement('afterbegin', detector)

    // 残りのコードは一緒なので省略
  }
)

FontFace() というのが FontFace オブジェクトのコンストラクターで、これでフォント・ファイルの URL などを設定して、新しいフォントを定義します。定義したフォントは document.fonts.add() でページの FontFaceSet に追加しないと使用できないので、同時に追加しておきます。document.createElement('div') 以降は…別に説明いらんよね😅

さらにモジュール化と Blank フォント差し替えして完成

とりあえずはこれで、すべての処理が JavaScript のみで完結するようになりました。でもまだこれだと「どのフォントを調べるか」の部分がスクリプト内にベタ書きで、そこを都度書き直さないといけません。いうほど汎用的ではないですね。

なので、実際にこのサイトで使ってるものはさらにもう少し手を加え、フォント検出する関数など6export できるようにモジュール化しています。あと幅 0 のフォントもさすがに Adobe Blank 2 そのままではサイズ大きすぎなので、古い Adobe Blankfonttoolspyftsubset を使ってサブセット化 + WOFF2 変換したものを使っています7。新しいフォントは次のようなコマンドによる作成で、サイズは 19,120 バイト → 592 バイトになりました。

% pyftsubset AdobeBlank.otf --unicodes=4a,46 --output-file=JFBlank.woff2 --flavor=woff2

ちなみにこのフォント (JFBlank.woff2) は 1 文字じゃなくて J, F の 2 文字が入ってるんですが、なぜかは分かりませんが 2 文字で作った方が 1 文字だけにするよりファイルサイズが小さくなったんですよね…😅 もちろん最初は遊びでこんなフォントにしたんですが、そういう事情もあってそのまま使っております。


以上でフォント検出スクリプトは完成です。あとはこれを利用して訪問ユーザーの環境にあるフォントをチェックし、html 要素に付与される class 名を使って font-weight を変更するのですが、この辺は別にたいしたことをやってない(カスタム・プロパティの値を変更してるだけ)ので、解説は省略とさせていただきます。まぁ本当の理由は「めんどくさい」です。涙

そんな訳で

本日は iOS に入ってるヒラギノ角ゴシックのウエイトがバージョンによって異なるため、特にバリアブル・フォントなどと合わせて和欧混植している場合にウエイトの不一致が発生する可能性があること、それを回避するために閲覧環境に入ってるフォントのウエイトに合わせて font-weight を調整できる仕組みを入れたい、ではそのためのスクリプトを書こうではないか…ということで実際に試行錯誤しつつ書いてみたよ! というお話でした。

あっと、大事なことを書き忘れてましたが、ヒラギノ角ゴシックで W4 や W5 を単独で検出できるのは、ヒラギノの各ウエイトがそれぞれ単独のフォント・ファイルとして提供されているからです。なので例えば Noto CJK の Super OTC みたいなフォント・コレクションに対して同じことをやろうとしても、確かうまくいかなかったはず(そんな記憶がある)です。ご注意ください。

まぁ今後リリースされる iOS (v19 以降) には基本的に W3〜W8 は普通に入ることになるでしょうから、こと iOS 対応という部分だけでいえばあと数年しか必要でない過渡期のスクリプト…ということになりそうですが、そうではない部分の対応で何かの役に立つこともあるかもしれませんので、もし使いたいという方がいらっしゃるのであれば、ご自由にお使いいただけましたらと思います。

ていうか幅 0 のフォントを使う手法、果たしてどこで読んだんだっけかなぁ。まじで全然思い出せない…「もしかしてオレの書いたアレじゃないの?」って方はさっさと名乗り出てください。涙

  1. Windows などの場合は sans-serif が適用されるので、各ユーザーがブラウザの設定で指定したフォントが使われてるはずです。 

  2. iOS 17.x には W5 が入ってるので本来であれば W5 が代替で使われるはずですが、iOS 側で互換性確保のために? 何かやってるらしく、なぜか W5 じゃなくて W3 が使われます…なんですが、実はウチみたいに @font-face を再定義してるとこの仕組みが上書きされて W5 での表示が復活したりするので、こういう曖昧な表現をするしかないのであります。涙 

  3. 他の環境のフォントには(macOS にはヒラギノ角ゴシックがフルウエイト入ってるし、Windows の Noto Sans JP やメイリオ、Android の Noto Sans CJK JP にしても)400 や 700 相当のウエイトが普通にあるはずなので。 

  4. 例えば Windows において Noto Sans JP がシステムに入っているかのチェックはできますが、それとブラウザのサンセリフ書体の設定が Noto Sans JP になってるかどうかはまた別の話なので、あまり意味はありません(サイト側の CSS でフォント固定していれば意味はありますが、まだ時期尚早かと思いますのでしてないです)。 

  5. もう少し正確に書くと「() 内に指定した文字列を font プロパティの値とした場合に、その指定が UA で満たされるために必要なフォントを UA がその時点で所有している FontFaceSet の中から探して読み込ませる」くらいになるかと思いますが、これじゃあまりにも長すぎるので省略しました! 涙 

  6. これももう少し正確に書くと「フォント検出する関数などを、フォントのロードが完了したら返す Promise」です。なので import する側で const detector = await initFontDetector のようにして使う必要があります(だからこんな名前で export がしてある)。 

  7. さらにこれが Data URI 化されるのですが、ここの処理は Webpack に任せているので、処理後の正確なバイト数は分かりません。