
はじめに
こんにちは、クラウドハウス労務でバックエンドエンジニアをしているfuhirです。 本記事では、1日目の Mari Imaizumi (@ima1zumi)さんによるKeynote セッション、Ruby Taught Me About Encoding Under the Hoodについて紹介させていただきます。
登壇者・本セッションについて
今回紹介するセッションの登壇者はハンドルネーム@ima1zumiとして活動し、Stores,inc に在籍されているMari Imaizumi さんです。(以後ブログ内では @ima1zumi さん と呼ばせていただきます)
2025年3月にRubyコミッターに就任し、主にIRB・Relineのメンテナンスをなさっています。
本セッションでは @ima1zumi さんが実際に出会って対処した issue や Unicodeのアップグレード作業の内容について具体的な事例を交えて紹介します。
まず、実際にセッションの本題に入る前に前提知識となる内容についておさらいしていきます。
文字コードとは
文字コードというと皆さんが思い浮かべるのは、Unicode・ASCII・UTF-8・Shift_JISだと思います。しかしこれらは一括りに文字コードと定義されていません。文字コードは符号化文字集合・文字符号化方式の2つに分けることができます。
Unicode/ASCIIは前者(符号化文字集合)、UTF-8/Shift_JISは後者(文字符号化方式)に分類され、この2つの文字集合・符号化方式の組み合わせを文字コードといいます。
では符号化文字集合・文字符号化方式は具体的にどのようなものなのか見ていきます。
符号化文字集合
符号化文字集合は、JISX 0221では以下のように定義されています。
文字と符号位置とを結び付けたものである符号化文字の集合
大まかには、それぞれの文字に符号を割り当てた表のようなものであると言えます。
ASCII も符号化文字集合であり、各文字に対して一意な数字が割り当てられていることが分かります。
文字符号化方式
同じく JISX 0221 を引用すると、文字符号化方式は以下のように定義されています。
符号化形式を構成する符号単位を8ビットの符号単位の並びに直列化する方法の規定です。
こちらは、符号化文字集合のコードポイントを実際のバイト列に変換する方式を指します。例えば Unicodeのコードポイントである「U+3042」を UTF-8(文字符号化方式) では「E3 81 82」というバイト列に変換します。
ちなみにUnicodeでは U+ は「Unicode のコードポイント」を示す接頭辞であり、後ろの数字がコードポイント本体です。
書記素クラスタ(Grapheme Cluster)
コンピュータ上では「符号化文字集合」と「文字符号化方式」を組み合わせることで、バイト列との相互に変換し、文字の表示を行っています。
とすると、文字とコードポイントは一対一に対応すると考えるのが自然です。しかし、Unicode では単一の文字を複数のコードポイントによって表現する場合があります。このような文字と複数のコードポイントとの対応を、書記素クラスタ(Grapheme Cluster)と呼びます。
例えば、「び」は Unicode で U+3073 というコードポイントに対応します。一方で、「ひ(U+3072)」+「゛(U+3099)」の2つのコードポイントの組み合わせとしても表すことが出来ます。
これで最低限の準備は整いました。ではセッションの本題である @ima1zumi さんが実際に対処した issue と 課題 について踏み込んでいきましょう。
reline に関連した家族絵文字バグ
こちらは👨👩👧👦(以降、家族絵文字と呼びます)に関連するバグになります。irb 上で家族絵文字を入力した後、BackSpace を二回入力すると...

何故かエラーが発生してしまいました。(このバグは既に@ima1zumi さんが修正しているため、再現させるには ruby の version を修正前のものに下げる必要があります)実はこちらのバグは、先程紹介した書記素クラスタが原因となっています。
irb(main):002> "👨<200d>👩<200d>👧<200d>👦".chars.size => 7 irb(main):001> "👨<200d>👩<200d>👧<200d>👦".chars => ["👨", "<200d>", "👩", "<200d>", "👧", "<200d>", "👦"]
ご覧のように、実はこの家族絵文字は合計で7つのコードポイントによって構成される書記素クラスタだったのです。(""はゼロ幅接合子(zero width joiner)というもので文字と文字の間に入って、表示上ひとつに「結合」させる特殊な文字です)BackSpace 入力時にカーソル移動等の処理が書記素クラスタ単位で正しく行われていなかったことが起因し、今回のバグが発生していたようです。
こちらのエラーはem_delete_prev_charという関数の内部で発生しています。削除する文字の幅を計算しようとしてget_mbchar_widthメソッドを実行した際に U+200d (ゼロ幅接合子のコードポイント) がどの条件分岐にも当てはまらず、nilを返しています。返ってきたnilに対して算術演算メソッドを呼び出してしまい、TypeErrorを起こしています。(issue 参照)
em_delete_prev_char:

get_mbchar_width:

原因は、実装時に表示上の文字単位と実際のコードポイント数の乖離している場合が考慮されていなかった点にあります。
こちらは、 @ima1zumi さんの修正によって直っています。
if last_byte_size != 0 and (last_mbchar + str).grapheme_clusters.size == 1
上記のコードによって前後のコードポイントが組み合わさり書記素クラスタを構成しているかを判別し、書記素クラスタを構成していた場合、幅がnilではなく0になるように
width = 0
を追加しています。
Upgrading Ruby to Unicode 15.1.0
RubyではUnicodeのアップグレードに対する対応が遅れていました。それによって、ヒンディー語、サンスクリット語で用いられるशिक्त(シャクティ)などの文字を正しく読み取ることができないなどの問題点がありました。
実際に @ima1zumi さんがRubyのUnicodeをアップグレードするにあたって苦労した経緯を紹介する前に、前提知識として「Unicodeのデータファイルの管理方法」「Rubyの文字処理の仕組み」「RubyのUnicode 最新 version への同期方法」を知る必要があります。
こちらを先に見てみましょう。
前提知識
Unicodeのデータファイルと文字処理の仕組み
まずはRubyが文字データの処理・正規化などで使用する文字データファイルと処理方法について説明します。
UnicodeData.txt
Unicodeでは、文字に関する情報やそれに付随する属性がテキストファイルで定義されています。
たとえば、「A」という文字は U+0041 というコードポイントで登録されており、そのカテゴリは「大文字のラテン文字(Uppercase Latin Letter)」とされています。

DerivedCoreProperties.txt
DerivedCoreProperies.txtは、複数のUnicodeファイルから抽出された派生プロパティが定義されているファイルです。
このファイルには、Math(数学記号)、Lowercase(小文字)、Uppercase(大文字)など、元になる文字情報を組み合わせて使用する様々な特性情報が体系的に整理されています。これらの派生プロパティは、文字の分類や処理において重要な役割を果たしています。

Onigmo
Onigmo は、Ruby に組み込まれている正規表現エンジンです。
このエンジンは、Unicode の文字特性に基づいたマッチングを可能にする \p{...} 構文の内部で使われており、たとえば "abc".match?(/\p{ASCII}/) のように記述することで、ASCII 文字かどうかを判定できます。
"abc".match?(/\p{ASCII}/) # => true
また、Onigmo は書記素クラスタ単位でのマッチングを行う \X 構文の処理にも利用されています。これは "noël".grapheme_clusters のように使うことで、「ë」のような結合文字も含めた視覚的な文字単位で文字列を扱うことができます。
"noël".grapheme_clusters # => ["n", "o", "ë", "l"]
RubyのUnicode 最新 version への同期方法
RubyにおけるUnicode対応の更新作業は、主に以下のステップで行われます。
まず、buildの設定に最新のUnicodeバージョンを反映させます。
次に、enc-unicode.rb スクリプトを実行し、Unicodeの公式データファイルである UnicodeData.txt や DerivedCoreProperty.txt を読み込みます。これらの情報をもとに、Name2ctype.h や casefold.h といったヘッダファイルを生成し、大文字・小文字変換などの情報を反映させます。
たとえば、casefold.h には A (0x0041) を a (0x0061) に対応させる変換ルールが {0x0041, {1|F|D, {0x0061}}} のような形式で定義されます。

最後に、最新のUnicodeのデータファイルに基づき、Ruby内部のテストコードを生成します。(必要に応じて一部は手動で追加)
前提知識がわかったところで実際にUnicodeを15.1.0に上げる際に出現したエラーについて見ていきましょう。
①DerivedCoreProperties.txtのフォーマット変更によるエラー
最初のエラーは DerivedCoreProperties.txt を読み込む処理で発生しました。

原因は、DerivedCoreProperties.txt のフォーマットの変更でした。このファイルは前半に ; を区切り文字としてプロパティが記載されています。ところが、Unicode 15.1.0 から、; を2つ利用するようなパターンが現れるようになったのです。
従来のフォーマット:

新しく追加されたフォーマット:

今回のフォーマット変更は、 Indic Conjunct Break に準拠したものです。これは、Linker(音のつなぎ)や Consonant(子音)、Extendといった要素を正確に表現することを目的としています。
新しいフォーマットに対応できるように実装を修正します:
elsif /^(\h+)(?:\.\.(\h+))?\s*;\s*(\w(?:[\w\s;]*\w)?)/ =~ line # $1: The first hexadecimal code point or the start of a range. # $2: The end code point of the range, if present. # If there's no range (just a single code point), $2 is nil. # $3: The property or other info. # Example: # line = "0915..0939 ; InCB; Consonant # Lo [37] DEVANAGARI LETTER KA..DEVANAGARI LETTER HA" # $1 = "0915" # $2 = "0939" # $3 = "InCB; Consonant" $2 ? cps.concat(($1.to_i(16)..$2.to_i(16)).to_a) : cps.push($1.to_i(16)) current = $3.gsub(/\W+/, '_') # "InCB; Consonant" を "InCB_Consonant" に変換
name = if name.start_with?('InCB') name.downcase.gsub(/_/, '=') # "InCB_Consonant" を "incb=consonant" に変換 else normalize_propname(name) end
このようにDerivedCoreProperties.txtのパースに適した形にenc-unicode.rbを変更し、読み込み処理が正常に終了するようになりました。
②書記素クラスタ境界判定のテスト失敗
さて、これで DerivedCoreProperties.txt を正確に処理できるようになりました。しかし、今度は自動生成されたテストが失敗してしまったのです。
テストは GraphemeBreakTest.txt から自動生成されたものです。「Grapheme Cluster Boundary Rulesに基づいて書記素クラスタを認識出来ているか」を検証するテストが作成されます。
Grapheme Cluster Boundary Rules は、コードポイントの境界を定めるルールです。Unicodeには書記素クラスタのように文字に複数のコードポイントが対応する場合があります。そのため、与えられたコードポイント列に対して、コードポイントの境界の定め方は複数考えられます。
例えば、U+3072 + U+3099 というコードポイントの列が与えられたとします。これは2つのコードポイントを一つの文字と解釈して「び」という文字に対応させることも出来ますし、U+3072 | U+3099 というように解釈して「ひ゛」という2つの文字に対応させることも出来ます。
このような場合にどこでコードポイント列を区切れば良いかのルールが定義されており、それがGrapheme Cluster Boundary Rules であるわけです。
先ほどの例の場合は、Grapheme Cluster Boundary Rules の GB9 に従うと、「ひ゛」ではなく「び」としてコードポイントの境界を認識するよう定められています。このように、コードポイントの境界の候補が複数あるものを尤もらしく解釈するためのルールが、Grapheme Cluster Boundary Rules というわけです。
そして、この Grapheme Cluster Boundary Rules にしたがってコードポイントの境界を認識されているかを検証するためのテストデータが GraphemeBreakTest.txt です。実際にファイルの中身を見てみましょう。

コードポイントと×÷記号が記述されていて、「 × は境界判定してはいけない」「 ÷ は境界判定してよい」という意味を持っています。
ここではコードポイント0915, 094D, 0924が × で繋がっているので3つのコードポイントでひとつの書記素クラスタを構成することを表しています。
しかし実際に生成されたテストを確認すると以下のようになっていました。

Unicode側では1つの文字として認識する事を期待しているところで、ruby の処理系側では、2つの文字として認識してしまっていました。
原因は、Onigmo の実装側が新しく追加された Grapheme Cluster Boundary Rules、GB9c に対応できていないことでした。

本来ならば「Consonant - [Extend|Linker]* - Linker - [Extend|Linker]* - Consonant」で一つの書記素クラスタを成さなければいけません。
具体的には「क, ् ,त」が並んでいた場合、それぞれのコードポイントを「Consonant、Linker、Consonantである」と判定し、この3つのコードポイントを一つの書記素クラスタとして、表示上「क्त」(デーバナーガリー文字の合字)と表現しなくてはいけません。
क(Consonant) - nil([Extend|Linker]*) - ्(Linker) - nil([Extend|Linker]*) - त(Consonant) → क्त
Onigmo 側では node_extended_grapheme_cluster という関数に書記素クラスタ境界判定用の正規表現ノードを生成する機能が備わっています。
この中にGB9cの定義に従ったノードを作成し、正規表現と同じ挙動で書記素クラスタとマッチするよう実装を行うことで、テストが通るように修正することが出来ました。
こちらが実際の変更箇所です。

終わり
これらの様々な課題を乗り越えたうえで Rubyの Unicode 15.1.0 へのアップグレードが完了しました。
今回の Ruby Kaigi を通して Ruby Committer を含めた様々なエンジニアの方のセッションを聞くことができ、自分自身の興味の種をたくさん得ることができました。文字コードもその1つです。このイベントで得た種を無駄にすることなく、これからも貪欲に好きな分野・興味を持った分野をたくさん学んでいきたいです!!
本記事で使用している画像はすべて @ima1zumi さんのスライドを使わせていただきました🙇
以下スライドです。
speakerdeck.com
Techouseでは、社会課題の解決に一緒に取り組むエンジニアを募集しております。 ご応募お待ちしております。