🦊

JS & CSS で画面幅と文字数にあわせてフォントを可変させる + 上限下限アリ

2022/12/07に公開

Overview

要はこういうことをしたい

  1. 画面両端いっぱいに一行タイトルテキストを出したい
  2. 文字数が増えたら一行を保ったままフォントサイズを小さくしたい
  3. ただし文字数が増えて小さくなりすぎないようにフォントサイズの下限を設定しそこまでいったら以降テキストは折り返す
  4. 画面幅を広くしたらタイトルテキストは両端いっぱいに一行を保ってフォントサイズを大きくしたい
  5. 画面幅が十分に広い場合にフォントサイズが大きくなりすぎないように上限を設定しそれ以上は一定のフォントサイズ

Lv.1 : CSS で上限と下限を設定しつつ画面幅に応じたフォントサイズ

これは font-sizevwclamp() を使えば実現できる
たとえばフォントサイズの下限と上限を 48px96px とする場合
かつ、画面内に「たまごかけごはん」と全角文字 8 文字を表示する場合

font-size: clamp(48px, calc(100vw / 8), 96px);

とする。 clamp(下限, 可変, 上限) である
画面幅いっぱいに「たまごかけごはん」と表示され、画面幅を変えると両端いっぱいに一行表示されたままフォントサイズが可変する
clamp の第 2 引数では「画面幅 100vw を 8 等分するようなフォントサイズ」が与えられている
つまり全角 8 文字がちょうど一行でおさまるようなフォントサイズである
画面を小さくしていくとフォントサイズは小さくなり、 48px を下回るとそれ以下にはならずテキストは折り返す。逆に画面を大きくしていくと 96px まで大きくなってそれ以上にはならない

これで与条件の一部は解決できたがまだ不十分

Lv.2 : JS を使って文字数が変わってもそれに応じたフォントサイズにする

たとえば React とかで props で任意のタイトルを受け取るときなどのイメージ
clamp の第 2 引数は 100vw ÷ 文字数 だったので文字数さえわかれば実現できる
ここはシンプルに length を使って文字数を取得する
( ソースコードは React を使っているつもりで見てください )

const str = 'たまごかけごはん'

...

return (
  <p style={{fontSize: `clamp(48px, ${100 / str.length}vw, 96px)`}}>{str}</p>
)

これでほぼほぼやりたいことは実現できているがまだ留意すべき点がある
半角文字の存在だ

全角文字はフォントサイズ (px) と文字の横幅の長さが一致している ( 特殊なフォントを除く ) ので画面幅を文字数で割った長さをフォントサイズとしてあてがえばよかった
たとえばフォントサイズ 100px の「あ」の文字の横幅は 100px である
しかし半角文字はそうはならない。半角文字では文字数の割に、それによって算出されたフォントサイズでは画面の端が余ってしまう


「たまごかけごはん」と同じ 8 文字の "illinois" は画面の端が余るのでもっと大きなサイズ ( < 96px ) で表示されても良いはずだ

そこで文字数のカウントを工夫することを考える
半角文字を使う場合は全角文字よりも多く画面内に収めることができるので全角文字を 1 文字とカウントするのを基準として半角文字を 1 以下でカウントする。たとえば半角文字は 1 文字分を 0.5 とカウントするようにすると、 "Illinois" は全角 4 文字分に相当することになるのでもう少し大きなフォントサイズになりそうだ

だが半角だから全角の半分の 0.5 にすればいいかというとそんな単純な話でもない
たとえば 1, l, I, i などと w, W, R, P, Q などとは明らかにテキストの幅が異なるのは見ての通り

同じ文字数の
Illinois
Kentucky
でも、並べてみれば一目瞭然かと思う
実際 100px の "I" の横幅は約 22px に対し "W" は 約 92px にもなる
つまり文字によってカウント数を変える必要が出てくるのだ

Lv.3 : 半角文字の幅を考慮して文字数をカウントしそれに応じたフォントサイズにする

半角文字のそれぞれの文字幅がわかっていればそれを使って見かけ上の文字数を作ることができる
全角を 1 としたときに
たまごかけごはんは 8 文字
Illinois は 0.22 + 0.2 + 0.2 + 0.21 + 0.53 + 0.53 + 0.21 + 0.47 = 2.57 文字
Kentucky は 0.6 + 0.51 + 0.53 + 0.3 + 0.53 + 0.5 + 0.49 + 0.49 = 3.42 文字
のように考える

ところでこの半角文字幅はどうやって知るのか?
こちらを参考にさせてもらい計算した
https://qiita.com/tkiryu/items/df16a9614aef4d57907d

const text = 'abcdefgHijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789`~!@#$%^&*()-_=+[{]}\\|;:\'",<.>/?'
const span = document.createElement('span')
const obj: any = {}
for (let i = 0; i < text.length; i++) {
  span.style.display = 'inline-block'
  span.style.fontSize = '100px'
  span.textContent = text[i]
  document.body.appendChild(span)
  obj[text[i]] = span.clientWidth / 100
}
console.log(obj)

本来は半角文字の全てについて考慮する必要があるのかもしれないがその数は甚大なれば、それはこちらの都合である程度絞り込んでも良いと考え text には上記の文字を含めた。一般的なキーボード上で入力できる記号類という認識だ
また、動的に取得すべきかとも思ったが辞書があったほうが計算が早くなるかと思い ( この辺は裏は取っていない ) 、予めリストを作った

これをもとに以下の ( 渾身の ) 辞書を得る ( for 文の出力をそのまま書き下したので順番は考慮されていないかと思うが正味問題はない )

これは全角の文字幅を 1 としたときの各半角文字の相対的な文字幅である
注意 : これはあくまで筆者の環境で使用しているフォントに対する数値であり、別のフォントであればまた異なる数値になるのでこの辞書をそのまま使うことはできない

const halfCharMap: { [key: string]: number } = {
  '0': 0.61,
  '1': 0.44,
  '2': 0.57,
  '3': 0.59,
  '4': 0.6,
  '5': 0.59,
  '6': 0.62,
  '7': 0.55,
  '8': 0.6,
  '9': 0.62,
  '!': 0.27,
  '"': 0.4,
  '#': 0.6,
  $: 0.6,
  '%': 0.81,
  '&': 0.67,
  "'": 0.25,
  '(': 0.32,
  ')': 0.32,
  '*': 0.4,
  '+': 0.6,
  ',': 0.21,
  '-': 0.43,
  '.': 0.21,
  '/': 0.28,
  ':': 0.21,
  ';': 0.21,
  '<': 0.6,
  '=': 0.6,
  '>': 0.6,
  '?': 0.49,
  '@': 0.87,
  A: 0.64,
  B: 0.6,
  C: 0.69,
  D: 0.68,
  E: 0.55,
  F: 0.53,
  G: 0.71,
  H: 0.7,
  I: 0.22,
  J: 0.5,
  K: 0.6,
  L: 0.52,
  M: 0.83,
  N: 0.7,
  O: 0.73,
  P: 0.58,
  Q: 0.73,
  R: 0.6,
  S: 0.59,
  T: 0.58,
  U: 0.7,
  V: 0.63,
  W: 0.92,
  X: 0.63,
  Y: 0.61,
  Z: 0.62,
  '[': 0.32,
  '\\': 0.28,
  ']': 0.32,
  '^': 0.6,
  '`': 0.5,
  a: 0.5,
  b: 0.55,
  c: 0.5,
  d: 0.55,
  e: 0.51,
  f: 0.3,
  g: 0.55,
  i: 0.21,
  j: 0.21,
  k: 0.49,
  l: 0.2,
  m: 0.8,
  n: 0.53,
  o: 0.53,
  p: 0.55,
  q: 0.55,
  r: 0.31,
  s: 0.47,
  t: 0.3,
  u: 0.53,
  v: 0.48,
  w: 0.72,
  x: 0.47,
  y: 0.49,
  z: 0.47,
  '{': 0.32,
  '|': 0.22,
  '}': 0.32,
  '~': 0.6,
  _: 0.53,
}

これを使って文字数をカウントする関数を定義する

const textCountByByte = (str: string): number => {
  let count = 0
  for (let i = 0; i < str.length; i++) {
    const halfChar = Object.keys(halfCharMap).find((e) => e === str[i])

    if (halfChar) {
      count += halfCharMap[halfChar]
    } else {
      count += 1
    }
  }

  return count
}

最後にこれを使ってフォントサイズを定義する

- <p style={{fontSize: `clamp(48px, ${100 / str.length}vw, 96px)`}}>{str}</p>
+ <p style={{fontSize: `clamp(48px, ${100 / textCountByByte(str)}vw, 96px)`}}>{str}</p>

完成

なお 100 / textCountByByte(str) の計算結果如何によっては ( 小数点の処理だったりで ) 一文字だけ折り返されてしまったりなどがあるかもしれない。実運用上は textCountByByte(str) で割るときに調整項を加えるなどをすると良いかもしれない

0.25 文字分余裕をもってフォントサイズを指定するようにした例
fontSize: `clamp(48px, ${100 / (textCountByByte(str) + 0.25)}vw, 96px)`

まとめと今後の展望

今回は vw , clamp から始まり文字幅の沼にハマりこのような方法にて可変なフォントサイズの実現を試みた
それなりにコントロールできるようなものができたので満足

ところで別の方法としては一行分を span で囲い、その長さが画面からはみ出るかどうかを判定し、画面幅に収まるまでフォントサイズを 1 ずつ小さくしていく、といったものも考えられる
また機会があればそちらにも挑戦したいし、おそらくこちらのほうがシンプルな実装になる気がする
こちらを参考にしてできそうだ
https://www.bravesoft.co.jp/blog/archives/15492

最後に

最後まで読んでいただきありがとうございます。今回の実装はかなり個別な調整が多くなってしまって正直洗練されたものとは言い難かったです。もし興味を持った方、より良い方法などを知っている方がいらっしゃればアドバイスなどをいただけると筆者の今後の励みになります。ではまたどこかでお会いしましょう

Discussion