LoginSignup
9
3

More than 1 year has passed since last update.

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

Last updated at Posted at 2022-08-03

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

はじめに

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

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

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

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

graphemesplit

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

const split = require('graphemesplit')

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

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

grapheme-splitter

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

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

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

Intl.Segmenter

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

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 が対応しない件ついては、こちらにその議論があります。

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

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

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

text[i]

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

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

漢字やひがらな等の日本語はだいたい大丈夫ですが、絵文字や一部の漢字で駄目になります。
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) を複数組み合わせて一つの文字を表現するサロゲートペアという方式が出てきました。

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

Array.from

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

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

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

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

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

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)が続く場合、更にその次の文字を混ぜる処理は、こんな感じでしょうか。

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' はセットで "葛󠄀" の文字になるようです。

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

適用先 コード範囲
SVS用 FE00 〜 FE0F
IVS用 E0100 〜 E01FE
 if (((0xfe00 <= cp) && (cp <= 0xfe0f)) ||
     ((0xe0100 <= cp) && (cp <= 0xe01fe))) {
      ;  // Variation Selector

絵文字修飾

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

Array.from("👍🏻👍🏼👍🏽👍🏾👍🏿").map(c => c.codePointAt(0).toString(16))
(10) ['1f44d', '1f3fb', '1f44d', '1f3fc', '1f44d', '1f3fd', '1f44d', '1f3fe', '1f44d', '1f3ff']
 if ((0x1f3fb <= cp) && (cp <= 0x1f3ff)) {
      ;  // Emoji Modifier
[ '👍🏻', '👍🏼', '👍🏽', '👍🏾', '👍🏿' ]

まとめ

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;
}

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

うまくいってそうです。

参考

9
3
2

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
9
3