JavaScript Text Character Splitting (1文字毎に分解)

JavaScript の文字列を1文字ごとに分解する際の注意です。

# はじめに

”良い天気😄” を [”良", "い", "天", "気", "😄”] に分解する時に、気づいた事をメモしました。

以下の解説とだいぶ重複するので、参考になるかもしれません。

- JavaScript textLength as halfWidth
  - https://qiita.com/yoya/items/5da038312279f98bdd28

# ライブラリを使う場合 (2022/09/28追記)

本エントリでの解説は日本語しか考慮していないのと、本来、Unicode の"1文字"は仕様が複雑なので、できれば専用ライブラリを使うのをお勧めします。

## graphemesplit

ネットで検索していると、よく紹介されているのがこれです。

- https://www.npmjs.com/package/graphemesplit

```javascript
const split = require('graphemesplit')

split('Z͑ͫ̓ͪ̂ͫ̽͏̴̙̤̞͉͚̯̞̠͍A̴̵̜̰͔ͫ͗͢L̠ͨͧͩ͘G̴̻͈͍͔̹̑͗̎̅͛́Ǫ̵̹̻̝̳͂̌̌͘!͖̬̰̙̗̿̋ͥͥ̂ͣ̐́́͜͞') // => ['Z͑ͫ̓ͪ̂ͫ̽͏̴̙̤̞͉͚̯̞̠͍','A̴̵̜̰͔ͫ͗͢','L̠ͨͧͩ͘','G̴̻͈͍͔̹̑͗̎̅͛́','Ǫ̵̹̻̝̳͂̌̌͘','!͖̬̰̙̗̿̋ͥͥ̂ͣ̐́́͜͞']
```

すごい。。。(凄すぎてこれが正しいのかさえ分からない。。)

## grapheme-splitter

海外の掲示板で見かけたのがこちら。

- https://github.com/orling/grapheme-splitter

```javascript
var splitter = new GraphemeSplitter();
splitter.splitGraphemes("🌷🎁💩😜👍🏳️‍🌈"); // returns ["🌷","🎁","💩","😜","👍","🏳️‍🌈"]
```

サンプルで var が使われている事に、歴史の重みを感じるか、怪しく感じるかは人によりそう。

## Intl.Segmenter

( 2022/9月時点で) Firefox と IE 以外ではブラウザ側で対応している Intl.Segmenter を使うと楽に文字分解できます。

- https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Global_Objects/Intl/Segmenter

```javascript
const str = "おはよう✋";
const segmenterJa = new Intl.Segmenter('ja-JP', { granularity: 'grapheme' });
const segments = segmenterJa.segment(str);
Array.from(segments).map(c => c.segment);
(5) ['お', 'は', 'よ', 'う', '✋']
```

### Firefox 未対応

Firefox が対応しない件ついては、こちらにその議論があります。

- https://bugzilla.mozilla.org/show_bug.cgi?id=1423593#c4

色々な話が出ていますが、主に、各ブラウザが実装してるルールベースの単語分割は区切りのない言語でうまくないので、辞書ベースで動く ICU をみんな使うべき。といった主張のようです。

```javascript
const str = "赤い頭の魚を食べる猫";
const segmenterJa = new Intl.Segmenter('ja-JP', { granularity: 'word' });
const segments = segmenterJa.segment(str);
Array.from(segments).map(c => c.segment);
(7) ['赤い', '頭', 'の', '魚', 'を', '食べる', '猫']
```

あれ。日本語はいい感じ？

### Firefox 未対応 (追記)

2024年4月16日の Firefox リリースで対応したようです。

<blockquote class="twitter-tweet"><p lang="ja" dir="ltr">4/16のFirefoxのリリースで主要ブラウザのIntl.Segmenter対応が揃うから、文字数がちゃんと判定できる時代が来る <a href="https://t.co/KMvePcmAnr">https://t.co/KMvePcmAnr</a></p>&mdash; ちるえの@リプ制限中 (@enotiru1221) <a href="https://twitter.com/enotiru1221/status/1776519420887896258?ref_src=twsrc%5Etfw">April 6, 2024</a></blockquote> <script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>

# text[i]

JavaScript は文字列の１つ１つの文字を配列要素として参照できます。
以下のようにして大抵の文字は１文字つづ取れます。

```js:
const text = "良い天気😄";
const charArr = []
for (let i = 0; i < text.length; i++) {
    charArr.push(text[i]);
}
console.log(charArr);
```

```js:
[ '良', 'い', '天', '気', '�', '�' ]
```

漢字やひがらな等の日本語はだいたい大丈夫ですが、絵文字や一部の漢字で駄目になります。
text[i].charCodeAt(0).toString(16) で各々16進数に変換するとこうです。

```
[ '826f', '3044', '5929', '6c17', 'd83d', 'de04' ]
```

絵文字"😄"のコードポイントは '1f604' ですが、'd83d','de04' の2つの値に分かれてしまっています。

Unicode に後の方で追加された絵文字は、16bit に収まらないコードポイントを持っていて、いわゆるサロゲートペアを処理する必要がありますが、この文字列のアクセス方法では対応していません。

## サロゲートペア

Java や JavaScript が作られた当初、Unicode は 16bit で表現できる文字種しかなく、16bit で収まっていたので、16bit(2byte)前提の UTF-16 で文字列を格納します。
しかし、世界中の文字を収蔵している間に 16bit では表現できなくなり、16bit (2byte) を複数組み合わせて一つの文字を表現するサロゲートペアという方式が出てきました。

こちらのサイトが詳しいです。参考までに

- 文字列とUnicode
   - https://jsprimer.net/basic/string-unicode/

# Array.from

JavaScript の文字列は Array.from を使うなりでイテレータを通すとサロゲートペアに対応した文字分解をします。
これで大抵の絵文字は１文字ずつ分解できます。

```js:
const text = Array.from("良い天気😄");
const charArr = []
for (let i = 0; i < text.length; i++) {
    charArr.push(text[i]);
}
console.log(charArr);
```

```
[ '良', 'い', '天', '気', '😄' ]
```

...(スプレッド記法)や for of でも charArr に同じ結果が入ります。

```js:
const text = "良い天気😄";
const charArr = [...text];
```

```js:
const text = "良い天気😄";
const charArr = []
for (const c of text) {
    charArr.push(c);
}
```

ただし、合成絵文字(Unicode でいう合字)は駄目です。あと、異体字も駄目です。

```js:
const text = "葛󠄀城市👨‍👩‍👧‍👦";
const charArr = []
for (const c of text) {
    charArr.push(c);
}
console.log(charArr);
```

```
[
  '葛', '󠄀', '城', '市',
  '👨', '‍', '👩', '‍',
  '👧', '‍', '👦'
]
```


codePointAt(0).toString(16) で16進数に変換すると、こうです。(charCodeAt は 16bit 限定なので、codePointAt を使います)

```
[
   '845b',  'e0100',   '57ce',  '5e02',
  '1f468',   '200d',  '1f469',  '200d',
  '1f467',   '200d',  '1f466'
]
```

'葛' と '城' の間の '' は 0xe0100 で異体字セレクタ。
絵文字の間に挟まっている '' は、0x200d で ZWJ(Zero Width Joiner)だと分かります。

# ZWJ (Zero Width Joiner)

```
> Array.from("👨‍👩‍👧‍👦").map(c => c.codePointAt(0).toString(16))
(7) ['1f468', '200d', '1f469', '200d', '1f467', '200d', '1f466']
```

文字の後に 0x200d(ZWJ)が続く場合、更にその次の文字を混ぜる処理は、こんな感じでしょうか。

```js:
const text = "😄👨‍👩‍👧‍👦";
const charArr = []
let chara = []
let needCode = 0;

for (const c of text) {
    const cp = c.codePointAt(0);
    if (cp === 0x200d) {  // ZWJ (Zero Width Joiner)
        needCode += 1;
    } else if (needCode > 0) {
        needCode -= 1;
    } else if (chara.length > 0) {
        charArr.push(chara.join(''));
        chara = [];
    }
    chara.push(c);
}
if (chara.length > 0) {
    charArr.push(chara.join(''));
    chara = [];
}

console.log(charArr);
```

```
[ '😄', '👨‍👩‍👧‍👦' ]
```

# 異体字セレクタ

異体字のケアも必要です。

```
> Array.from("葛󠄀城市").map(c => c.codePointAt(0).toString(16))
(4) ['845b', 'e0100', '57ce', '5e02']
```

'845b' と 'e0100' はセットで "葛󠄀" の文字になるようです。

日本語環境で使われそうな異体字セレクタはこの２つ。

| 適用先 | コード範囲 |
|---|---|
| SVS用  | FE00 〜 FE0F |
| IVS用 | E0100 〜 E01FE |

- [https://ja.wikipedia.org/wiki/異体字セレクタ](https://ja.wikipedia.org/wiki/%E7%95%B0%E4%BD%93%E5%AD%97%E3%82%BB%E3%83%AC%E3%82%AF%E3%82%BF)

```
 if (((0xfe00 <= cp) && (cp <= 0xfe0f)) ||
     ((0xe0100 <= cp) && (cp <= 0xe01fe))) {
      ;  // Variation Selector
```

# 絵文字修飾

絵文字の肌の色を変える、emoji modifier にも対応します。
これも異体字セレクタのように、後にきます。

```js:
Array.from("👍🏻👍🏼👍🏽👍🏾👍🏿").map(c => c.codePointAt(0).toString(16))
(10) ['1f44d', '1f3fb', '1f44d', '1f3fc', '1f44d', '1f3fd', '1f44d', '1f3fe', '1f44d', '1f3ff']
```

```js:
 if ((0x1f3fb <= cp) && (cp <= 0x1f3ff)) {
      ;  // Emoji Modifier
```

```
[ '👍🏻', '👍🏼', '👍🏽', '👍🏾', '👍🏿' ]
```

# まとめ

```js:
function textCharaSplit(text) {
    const charArr = []
    let chara = []
    let needCode = 0;
    for (const c of text) {
        const cp = c.codePointAt(0);
        if (cp === 0x200d) {  // ZWJ (Zero Width Joiner)
            needCode += 1;
        } else if (((0xfe00 <= cp) && (cp <= 0xfe0f)) ||
                   ((0xe0100 <= cp) && (cp <= 0xe01fe))) {
                ;  // Variation Selector
        } else if ((0x1f3fb <= cp) && (cp <= 0x1f3ff)) {
                ;  // Emoji Modifier
        } else if (needCode > 0) {
            needCode -= 1;
        } else if (chara.length > 0) {
            charArr.push(chara.join(''));
            chara = [];
        }
        chara.push(c);
    }
    if (chara.length > 0) {
        charArr.push(chara.join(''));
        chara = [];
    }
    return charArr;
}

```

```js:
> textCharaSplit("A01赤-葛󠄀城市😄👨‍👩‍👧‍👦!");
(12) ['A', '0', '1', '赤', '-', '葛', '󠄀', '城', '市', '😄', '👨‍👩‍👧‍👦', '!']
```

うまくいってそうです。

# 参考

- JavaScriptでの絵文字の扱われ方を知っていますか？
   - https://tech.smartcamp.co.jp/entry/emoji-problem-on-javascript
- JavaScript における文字コードと「文字数」の数え方
  - https://blog.jxck.io/entries/2017-03-02/unicode-in-javascript.html
- JavaScriptで絵文字とサロゲートペアと結合文字とgrapheme clusterを正しく扱うのに少し苦労した話
  - https://qiita.com/amanoese/items/68bb9999829de4323302

- JavaScript: 異体字セレクターを考慮して文字数を求める
  - https://blog.sarabande.jp/post/79238170880
- [https://ja.wikipedia.org/wiki/異体字セレクタ](https://ja.wikipedia.org/wiki/%E7%95%B0%E4%BD%93%E5%AD%97%E3%82%BB%E3%83%AC%E3%82%AF%E3%82%BF)
- 字形選択子（異体字セレクタ） Variation Selectorsの文字一覧 - 1 Unicode U+FE00～U+FE0F(65025文字目～65040文字目)
  - https://0g0.org/category/FE00-FE0F/1/
- [https://www.weblio.jp/wkpja/content/その他の記号及び絵記号_その他の記号及び絵記号の概要](https://www.weblio.jp/wkpja/content/%E3%81%9D%E3%81%AE%E4%BB%96%E3%81%AE%E8%A8%98%E5%8F%B7%E5%8F%8A%E3%81%B3%E7%B5%B5%E8%A8%98%E5%8F%B7_%E3%81%9D%E3%81%AE%E4%BB%96%E3%81%AE%E8%A8%98%E5%8F%B7%E5%8F%8A%E3%81%B3%E7%B5%B5%E8%A8%98%E5%8F%B7%E3%81%AE%E6%A6%82%E8%A6%81)