JavaScript の文字列を1文字ごとに分解する際の注意です。
はじめに
”良い天気😄” を [”良", "い", "天", "気", "😄”] に分解する時に、気づいた事をメモしました。
以下の解説とだいぶ重複するので、参考になるかもしれません。
- JavaScript textLength as halfWidth
ライブラリを使う場合 (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 未対応
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) ['赤い', '頭', 'の', '魚', 'を', '食べる', '猫']
あれ。日本語はいい感じ?
Firefox 未対応 (追記)
2024年4月16日の Firefox リリースで対応したようです。
4/16のFirefoxのリリースで主要ブラウザのIntl.Segmenter対応が揃うから、文字数がちゃんと判定できる時代が来る https://t.co/KMvePcmAnr
— ちるえの@リプ制限中 (@enotiru1221) April 6, 2024
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) を複数組み合わせて一つの文字を表現するサロゲートペアという方式が出てきました。
こちらのサイトが詳しいです。参考までに
- 文字列とUnicode
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', '赤', '-', '葛', '󠄀', '城', '市', '😄', '👨👩👧👦', '!']
うまくいってそうです。
参考
-
JavaScriptでの絵文字の扱われ方を知っていますか?
-
JavaScript における文字コードと「文字数」の数え方
-
JavaScriptで絵文字とサロゲートペアと結合文字とgrapheme clusterを正しく扱うのに少し苦労した話
-
JavaScript: 異体字セレクターを考慮して文字数を求める
-
字形選択子(異体字セレクタ) Variation Selectorsの文字一覧 - 1 Unicode U+FE00~U+FE0F(65025文字目~65040文字目)
-
https://www.weblio.jp/wkpja/content/その他の記号及び絵記号_その他の記号及び絵記号の概要