JavaScriptで絵文字とサロゲートペアと結合文字とgrapheme clusterを正しく扱うのに少し苦労した話


皆さんはUnicodeや絵文字についてどのくらい理解していますか?
私は全く理解できていません。

JavaScriptで絵文字を扱おうとしたら苦労した話を書きます。誰かの参考になれば幸いです。

経緯

MuscularというジョークコマンドをNode.jsで開発していました。

これは、ボディビルダーとともにテキストを叫んだ感じで表示するという単純なアプリケーションでした。
cowsay」と「echo-sd」と「筋肉」をあわせたようなコマンドです。

$ muscular shout ナイスバルク
        .-~-.          
        /   \          
        |   /          
     ,_-/ ,.*`--.,     
   .r ;       ``  \    
  .`,`   ,:  ,.`A`,\   
 /,`/\`''  ''  ? \` \  
/  /  \ ; , ; /   )  } _人人_
| /    \:':': |   | 7  > ナ <
| |    ) ':'  |  ,` /  > イ <
( \    `-,_,-~}  | l   > ス <
 `~   / `,  /' \ '"'   > バ <
     ,7   \/   |.      > ル <
     {  ; |, ,  )      > ク <
     |,`: |`,`: |       ̄Y^Y^ ̄
     \` , /\`.; /      
      |`;/  \ , |      
      \``\   |` (      
      \ . )  {  /      
       \ `/  (  }      
        \ |  | /       
        ) \  | |       
     c~^_~d  V _`,     

絵文字が崩れれる問題

このコマンドでテキストを縦書きで表示しているのですが、
ここに絵文字が入ると崩れる問題が生じました。(issues #5)

$ muscular shout "$(echo -e "😫")"
        .-~-.
        /   \
        |   /
     ,_-/ ,.*`--.,
   .r ;       ``  \
  .`,`   ,:  ,.`A`,\
 /,`/\`''  ''  ? \` \
/  /  \ ; , ; /   )  }
| /    \:':': |   | 7
| |    ) ':'  |  ,` /  _人人_
( \    `-,_,-~}  | l   > �  <
 `~   / `,  /' \ '"'   > �  <
     ,7   \/   |.       ̄Y^Y^ ̄
     {  ; |, ,  )
     |,`: |`,`: |
     \` , /\`.; /
      |`;/  \ , |
      \``\   |` (
      \ . )  {  /
       \ `/  (  }
        \ |  | /
        ) \  | |
     c~^_~d  V _`,

これを解決する必要があります。

参考にした情報

以下のリンクを読めば今回なぜ問題が起きたのかすべてを理解できます。

JavaScript における文字コードと「文字数」の数え方

すばらしいまとめと説明でした。
リンク先を読めばこれより下にある文章は読まなくてもJavaScriptにおける文字コード問題はほぼ解決できるでしょう。

なにが問題だったのか

実際に書いたコードはもう少し複雑ですが、
単純化して説明するために1行の文字列を縦書きで表示するプログラムで説明します。

'天気☀☁'.split('').join('\n')
// 天
// 気
// ☀
// ☁

このコードはUTF-16において16ビットで収まる文字であれば十分機能します。
しかし、サロゲートペアの範囲(U+10000 〜 U+10FFFF)に含まれる文字になると事情が異なります。

'💩🚽'.split('').join('\n')
// \ud83d
// �
// \ud83d
// �

これは、JavaScriptの内部表現はUTF-16であり、JavaScriptの正規表現の "." が1文字ではなくUTF-16のデータ16bitに一致することを表しているためのようです。

サロゲートペアな文字に対応する

こういう1文字がほしいときのために、
JavaScriptの正規表現にはES2015から "u" フラグ というオプションが追加されたみたいです。

また、String.prototype.splitはUTF-16のコードユニット毎にしか分割できないため、
String.prototype.matchを使う必要があります。

'天気☀☁💩🚽'.match(/./ug).join('\n')
// 天
// 気
// ☀
// ☁
// 💩
// 🚽

また、スプレッド構文でも分解できるようです。こっちのほうが短くて良いですね。

[ ...'天気☀☁💩🚽' ].join('\n')
// 天
// 気
// ☀
// ☁
// 💩
// 🚽

結合文字列の問題

これで問題は解決したかに思われましたが、Unicodeには結合文字列という、
基底文字+結合文字で1文字を表す文字列が存在します。

[ ...'👨‍👩‍👧‍👦' ].join('\n')
// 👨
// ‍
// 👩
// 
// 👧
// 
// 👦
[ ...'pͪoͣnͬpͣoͥnͭpͣa͡inͥ' ].join('\n')
// p
// ͪ
// o
// ͣ
// n
// ͬ
// p
// ͣ
// o
// ͥ
// n
// ͭ
// p
// ͣ
// a
// ͡
// i
// n
// ͥ

この状態では家族は離散してしまい、お腹が痛いというメッセージも伝わらないです。

この問題は「基底文字+結合文字で1文字を表す」というUnicodeにおける文字(CodePoint)と見た目の文字数が一致していないことに起因するみたいです。

この問題は多くの場合、Unicode正規化 のみで解決できるかもしれません。
しかしZWJ (U+200D)を使用した絵文字 " 👨‍👩‍👧‍👦 "は7文字で1文字に見える合字を表しており、
このような文字はUnicode正規化では問題を解決することができません。

結合文字列とgrapheme clusterに対応する

しかしながら「1文字に見える単位」にも複雑ながら定義がされているらしいです。
UAX #29 Unicode Text Segmentation
grapheme cluster というUnicodeにおいて1文字に見える範囲と、その境界を判断するルールらしいです。

これを実装すれば解決できそうですね………
実装できないのでライブラリ使います。

使用した素晴らしいライブラリです。
https://www.npmjs.com/package/graphemesplit

const split = require('graphemesplit')

split('👨‍👩‍👧‍👦' ).join('\n')
// 👨‍👩‍👧‍👦
split('pͪoͣnͬpͣoͥnͭpͣa͡inͥ').join('\n')
// pͪ
// oͣ
// nͬ
// pͣ
// oͥ
// nͭ
// pͣ
// a͡
// i
// nͥ

すばらしいです!これで家族が離散しなくて済みます!

まとめ

文字の問題を片付け、正しく縦書きにして表示できるようになりました。

$ muscular shout '天気☀☁💩🚽👨‍👩‍👧‍👦'
   ,~-,    .-,    r~-.    
  / r-d   /   \   `w`,\   
,7 /      \   /      \ \  
/  ^"`-'v/'`-y|`"'-"^-` ) 
`\,_ _,;,   ,     A,,  /  
    "'\ `:,.'`..,`)' `'   
       \ ,  ;, ,,r`       
        `, .,.` )         _人人_
         ) `;.  }         > 天 <
         |._,,_.|         > 気 <
         ^\    .'.        > ☀  <
        /  \, /  \,       > ☁  <
        | ; `/ `  )       > 💩 <
        | ,  |  . |       > 🚽 <
        \,`,,A ;`,}       > 👨‍👩‍👧‍👦 <
        ';  ) \   ,        ̄Y^Y^ ̄
         \',/  (  }       
         \  \  /  |       
          ( ,)(   ,       
          \ ;} :`/        
           \ | )V         
            )\| |         
         ,^'_}?__`,       

しかし、フォントの問題から様々な環境では崩れるかもしれません。少なくとも私の手元の環境では崩れます。
Unicodeは難しいですね。
また、"文字"という概念を理解するのも難しそうです。


何か誤っている点がありましたらご教示いただけると幸いです。