🥲

全角文字を半角文字の2文字分として数えるの、意外と難しい問題【Rails】

2024/08/15に公開3

ラブグラフでエンジニア兼CTOをしております横江( @yokoe24 )です。

文字数バリデーションを付けたいことはよくあると思います。
ただ、ユーザーのニックネームなどの場合、
「正直、表示する場所の横幅の問題だから、半角文字を0.5文字で全角文字を1文字と数えたいなぁー」と思うこともあると思います。

半角文字を0.5文字で全角文字を1文字と数える
至って単純そうなこの仕組み、なかなかの難儀を迎えるのでした……。

各文字のバイトサイズを考えればなんとかなる?!

まずは ChatGPT に聞いてみましょう!

Rails で、User モデルの name カラムに対するバリデーションを作っています。
半角文字を0.5文字、全角文字を1文字と数えるようにしつつ、
最大で4文字までの入力を許すように作りたいんだけど、どうすればいいかなあ?
絵文字の入力も許したいので、それを考慮して実装してもらえるとうれしいです。

このように質問してみました。
すると、ChatGPT は以下のようなコードを出力してくれます。

class User < ApplicationRecord
  validate :validate_name_length

  private

  def validate_name_length
    return if name.blank?

    total_length = name.each_char.sum do |char|
      # 絵文字や特殊な全角文字を含むかもしれないので、その処理もここに含める
      char.bytesize == 1 ? 0.5 : 1
    end

    if total_length > 4
      errors.add(:name, 'は全角で4文字以内(半角換算で8文字以内)にしてください')
    end
  end
end

半角文字は1バイト文字だから0.5文字と数えて、それ以外のバイト数の文字は1文字と数える。
なるほど。一見、これは正しく動作しそうなコードです。

> User.new(name: "あああaa").valid?
=> true
> User.new(name: "あああaaa").valid?
=> false
> User.new(name: "ああああ").valid?
=> true
> User.new(name: "ああああa").valid?
=> false

うんうん、よさそうですね!
しかし、絵文字が来ると以下のように破綻します😱

> User.new(name: "猫好き🐱").valid?
=> true
> User.new(name: "猫好き❤️").valid?
=> false
> User.new(name: "1️⃣").valid?
=> true
> User.new(name: "1️⃣2️⃣").valid?
=> false
> User.new(name: "我輩猫👨🏻‍🦱").valid?
=> false

ヤバい emoji たち

🐱 は1文字として数えられたのですが、
❤️ は1文字ではないようです。

なぜこんなことが起きたのでしょう?

これは、 each_char でなにが起きていたのかを見るとわかります。

> "猫好き🐱".each_char.to_a
=> ["猫", "好", "き", "🐱"]
> "猫好き🐱".each_char.map(&:bytesize)
=> [3, 3, 3, 4]
> "猫好き🐱".length
=> 4

問題なかったパターンがこちら。
全角文字は3バイト文字、絵文字 🐱 は4バイト文字のようです。
コードでは「1バイト文字以外は1文字として数える」となっていましたから、この文字列は4文字ですね。

> "猫好き❤️".each_char.to_a
=> ["猫", "好", "き", "❤", "️"]
> "猫好き❤️".each_char.map(&:bytesize)
=> [3, 3, 3, 3, 3]
> "猫好き❤️".length
=> 5

そしてさっそくおかしくなったのがこちら!
❤️ は3バイト文字と3バイト文字の結合で出来ているらしいです! なんですと?!

ってなわけで、 猫好き❤️ は全角文字5文字分の文字列なのです🙄

> "1️⃣2️⃣".each_char.to_a
=> ["1", "️", "⃣", "2", "️", "⃣"]
> "1️⃣2️⃣".each_char.map(&:bytesize)
=> [1, 3, 3, 1, 3, 3]
> "1️⃣2️⃣".length
=> 6

ますますヤバいのがやってきました!
1️⃣ は、1バイト文字+3バイト文字+3バイト文字で出来上がっています!

どういうことなの……🤯

> "我輩猫👨🏻‍🦱".each_char.to_a
=> ["我", "輩", "猫", "👨", "🏻", "‍", "🦱"]
> "我輩猫👨🏻‍🦱".each_char.map(&:bytesize)
=> [3, 3, 3, 4, 4, 3, 4]
> "我輩猫👨🏻‍🦱".length
=> 7

極めつけはこちら!

👨🏻‍🦱 は、4バイト文字+4バイト文字+3バイト文字+4バイト文字で出来ています。
この絵文字だけで全角4文字分です🫠

ってか 🦱 ってなんですか?!
スマホとかで絵文字を選ぶときの一覧に出てこない絵文字じゃないですか!

Emoji ZWJ Sequences などなど

文字コードに慣れ親しんでいる方なら、
「UTF-16 のサロゲートペアみたいだな……」と思われたかもしれません。

今回の、組み合わせによる絵文字の生成は
Emoji ZWJ Sequence (Emoji Zero Width Joiner Sequence=「ゼロ幅の文字を用いて絵文字をつなぐ処理方法」)などなど、様々な絵文字の合成方法に基づきます。
以下の記事が詳しいです。

一次情報は Unicode Standard(Unicode標準)の策定をおこなう Unicode, Inc. の
Unicode® Technical Standard #51 のページ内に記載されています。


これはほんの一部だけ

❤️ emoji variation sequence

❤️ は emoji variation selector が文字コード U+FE0F として付いています。
この文字があるかないかで表示が変わるのが、 emoji variation sequence です。

> [0x2601, 0xFE0F].pack("U*")
=> "☁️"
> [0x2601].pack("U*")
=> "☁"

> [0x2764, 0xFE0F].pack("U*")
=> "❤️"
> [0x2764].pack("U*")
=> "❤"

この記事での表示は変わらないのですが、実際に irb で試すと違います。

0️⃣ emoji keycap sequence

1️⃣ や 2️⃣ などは emoji keycap sequence によって表示が形成されています。

> "1️⃣".each_char.to_a
=> ["1", "️", "⃣"]
> "1️⃣".each_char.map { |char| "U+" + ("0000" + char.unpack("U*")[0].to_s(16).upcase)[-6..].sub(/^0{0,2}/, "") }
=> ["U+0031", "U+FE0F", "U+20E3"]

この U+0031 は数字の 1 ですが、
emoji keycap sequence においては emoji modifier という存在です。

その次の U+FE0F が、
先ほど出てきた emoji variation selector

最後の U+20E3 が、
enclosing keycap (囲み記号)と呼ばれるものです。

👨🏻‍🦱 emoji modifier sequence + emoji ZWJ sequence

👨🏻‍🦱 は emoji modifier sequenceemoji ZWJ sequence の組み合わせです。

> "👨🏻‍🦱".each_char.to_a
=> ["👨", "🏻", "‍", "🦱"]
> "👨🏻‍🦱".each_char.map { |char| "U+" + ("0000" + char.unpack("U*")[0].to_s(16).upcase)[-6..].sub(/^0{0,2}/, "") }
=> ["U+1F468", "U+1F3FB", "U+200D", "U+1F9B1"]

U+1F3FBemoji modifier、肌色を定義しています。

そして Zero Width Joiner である U+200D を挟んだあとで、
U+1F9B1emoji presentation selector として追加されています。


よ〜く見ると、最後の最後で髪型が少し変わっています

🇯🇵 emoji flag sequence

🇯🇵 などの国旗は emoji flag sequence で形成されています。

イングランド・スコットランド・ウェールズといった、今のイギリスを形成する一部の国旗は
emoji tag sequence によって形成されています。
北アイルランドの国旗の絵文字はないようです……

> "🇯🇵".each_char.map { |char| "U+" + ("0000" + char.unpack("U*")[0].to_s(16).upcase)[-6..].sub(/^0{0,2}/, "") }
=> ["U+1F1EF", "U+1F1F5"]

> "🏴󠁧󠁢󠁥󠁮󠁧󠁿".each_char.map { |char| "U+" + ("0000" + char.unpack("U*")[0].to_s(16).upcase)[-6..].sub(/^0{0,2}/, "") }
=> ["U+1F3F4", "U+E0067", "U+E0062", "U+E0065", "U+E006E", "U+E0067", "U+E007F"]

U+1F1EF は「J」、
U+1F1F5 は「P」を示すそうです。

イングランドの方はどのように出来ているかというと、
U+1F3F4 はベース( tag_base )となる旗 🏴

その次からが tag_spec と呼ばれるもの。

gbeng。つまりグレートブリテン(GB)、イングランド(England)からなるタグですね。

そして最後の U+E007Ftag_end と呼ばれる Cancel Tag です。

それを踏まえて、さて、どうする……?!

長いこと引っ張ってしまいましたが、実は Ruby 2.5 以降には、
each_grapheme_cluster というメソッドが存在します❣️❣️

> "aåあ❤️1️⃣👨🏻‍🦱🇯🇵🏴󠁧󠁢󠁥󠁮󠁧󠁿".each_char.to_a
=> ["a", "å", "あ", "❤", "️", "1", "️", "⃣", "👨", "🏻", "‍", "🦱", "🇯", "🇵", "🏴", "󠁧", "󠁢", "󠁥", "󠁮", "󠁧", "󠁿"]
> "aåあ❤️1️⃣👨🏻‍🦱🇯🇵🏴󠁧󠁢󠁥󠁮󠁧󠁿".each_char.map(&:bytesize)
=> [1, 2, 3, 3, 3, 1, 3, 3, 4, 4, 3, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4]

> "aåあ❤️1️⃣👨🏻‍🦱🇯🇵🏴󠁧󠁢󠁥󠁮󠁧󠁿".each_grapheme_cluster.to_a
=> ["a", "å", "あ", "❤️", "1️⃣", "👨🏻‍🦱", "🇯🇵", "🏴󠁧󠁢󠁥󠁮󠁧󠁿"]
> "aåあ❤️1️⃣👨🏻‍🦱🇯🇵🏴󠁧󠁢󠁥󠁮󠁧󠁿".each_grapheme_cluster.map(&:bytesize)
=> [1, 2, 3, 6, 7, 15, 8, 28]

each_char メソッドと異なり、
each_grapheme_cluster メソッドでは絵文字も1文字単位で分けてくれます。

なので!!
今回の 半角文字を0.5文字で全角文字を1文字と数える 機能は、このように計算させればよかったのです!

> "a∑あ❤️1️⃣👨🏻‍🦱🇯🇵🏴󠁧󠁢󠁥󠁮󠁧󠁿".each_grapheme_cluster.sum { |char| char.bytesize == 1 ? 0.5 : 1 }
=> 7.5

これで問題解決です!🌟

おまけ:JavaScript ではどうやるか

JavaScript でも絵文字を1文字として数えることができ、
こちらのサイトに詳しく書かれています

それを参考に実装することができました!!

/**
 * 半角文字を0.5文字、全角文字を1文字と数えてカウント
 */
function countZenkakuChars(str) {
  const segments = [...new Intl.Segmenter().segment(str)];

  let count = 0;
  segments.forEach(segment => {
    const char = segment.segment;
    const bytesize = (new Blob([char])).size;

    if (bytesize === 1) {
      count += 0.5;
    } else {
      count += 1;
    }
  });

  return count;
}

https://jsfiddle.net/nekonenene/31dv5Lxf/68/ にて動作を確認できます。
(※2024年4月現在、Firefox では Segmenter の実装が追いついていないため、Polyfill を入れることで対応させています)

おしまい

以上、実は難しい 「全角1文字、半角0.5文字で数える方法」 のお話でした〜!

emoji によってバイト数が違うということを知れて、いい勉強になりました!

ラブグラフのエンジニアブログ

Discussion

akkuakku

Unicodeって最初の全文字を16bitで収録するという目標からはかなり遠ざかってしまった感じですね
まあ漢字が多すぎるとか異体字とかあげていけばキリがないので最初から無理だったのかもしれないです...
逆転の発想で半角文字のリストを作ってそれ以外を全角文字としてループでカウントするのもいいのかもしれないと思いました

横江@ラブグラフ横江@ラブグラフ

Unicodeって最初の全文字を16bitで収録するという目標

コメントありがとうございます!!
そういう目標があったんですね! 知らなかったです!

逆転の発想で半角文字のリストを作ってそれ以外を全角文字としてループでカウントする

これアリかと思いきや、
記事でも軽く触れていますが、少なくとも Ruby や Python では、

str = "I am 👨🏻‍🦱"
=> "I am 👨🏻‍🦱"

str.each_char.to_a
=> ["I", " ", "a", "m", " ", "👨", "🏻", "‍", "🦱"]
str = "I am 👨🏻‍🦱"
list = [char for char in str]

print(list)
=> ['I', ' ', 'a', 'm', ' ', '👨', '🏻', '\u200d', '🦱']

このように人間には1つの文字に見えるものを4つの文字として分解するんですよね。

なので、1文字ごとのループでは成し遂げられないのですよ〜😿

akkuakku

あ、浅はかだったみたいですね...
プロポーショナルフォントだとまた文字幅が違うのでまた大変かもしれません