CLOVER🍀

That was when it all began.

Java 17で文字を数える

これは、なにをしたくて書いたもの?

前に、Java 11でこんなエントリーを書きました。

Java 11で文字を数える - CLOVER🍀

この時の主なテーマはBreakIteratorだったのですが、BreakIteratorではうまく扱えない絵文字があることがわかりまして。

その後、Java 13以降であればUnicode拡張書記素クラスタ境界(正規表現)を使うと扱えることがわかり、今回こちらを試してみることに
しました。

BreakIteratorでうまく扱えない絵文字

たとえば、以下のようなものです。

絵文字 数値文字参照
👆🏼 👆🏼
🇦🇿 🇦🇿
🏴󠁧󠁢󠁥󠁮󠁧󠁿 🏴󠁧󠁢󠁥󠁮󠁧󠁿
👨‍👩‍👧‍👦 👨‍👩‍👧‍👦

複数の絵文字・文字で、ひとつの絵文字を形成するような文字です。

JavaとUnicodeのバージョン

JavaがサポートしているUnicodeのバージョンですが、CharacterのJavadocに書いてありました。

Character (Java SE 17 & JDK 17)

Java 17の時点でUnicode 13.0をサポートしています。

現在のUnicodeは15.0です。

Unicode® 15.0.0

この中に、Emoji 15.0が含まれていることが書かれています。

20 emoji characters, including hair pick, maracas, jellyfish, khanda, and pink heart. For complete statistics regarding all emoji as of Unicode 15.0, see Emoji Counts. For more information about emoji additions in version 15.0, including new emoji ZWJ sequences and emoji modifier sequences, see Emoji Recently Added, v15.0.

Emoji Recently Added, v15.0

Unicodeの中に、絵文字がバージョン入りで含まれているようです。

Unicode 13.0を見てみましょう。

Unicode 13.0.0

こちらでは、Emoji 13.0での追加絵文字が書かれています。

55 emoji characters. For complete statistics regarding all emoji as of Unicode 13.0, see Emoji Counts. For more information about emoji additions in version 13.0, including new emoji ZWJ sequences and emoji modifier sequences, see Emoji Recently Added, v13.0.

Emoji Recently Added, v13.0

絵文字

現時点での絵文字に関する情報、そして一覧はこちら。

Unicode Emoji v15.0

Emoji List, v15.0

Emoji 13.0の場合。

Unicode Emoji v13.0

Emoji List, v13.0

ですが、ちゃんとしたリストを見ようと思うと、こちらを参照した方が良さそうです。

Index of /Public/emoji

たとえば、Emoji 13.0の場合。

https://unicode.org/Public/emoji/13.0/emoji-sequences.txt

https://unicode.org/Public/emoji/13.0/emoji-zwj-sequences.txt

絵文字と構造

絵文字とその構造は、以下のドキュメントに記載されています。

UTS #51: Unicode Emoji

絵文字は、シーケンスというもので分類されるようです。

Unicode Emoji / Introduction / Definitions / Emoji Sequences

シーケンスには以下があります。

  • emoji core sequence
    • emoji character
    • emoji keycap sequence
    • emoji modifier sequence
    • emoji flag sequence
  • emoji zwj sequence
  • emoji tag sequence

先ほど、emoji-sequences.txtとemoji-zwj-sequences.txtの2つのファイルを紹介しましたが、どのファイルにどのシーケンスが
含まれているかは、以下に記載があります。

Unicode Emoji / Introduction / Definitions / Emoji Sets

また以下は最新のEmojiバージョンで追加された絵文字ですが、どのシーケンスに分類されるかが記載されています。

Emoji Sequences, v15.0

複数の絵文字を組み合わせる話は、以下あたりに記載があります。

ZWJというのは、ゼロ幅接合子(Zero Width Jointer)のことで、コードポイントではU+200Dを指します。

ゼロ幅接合子 - Wikipedia

こちらを使って、複数の絵文字を接合して表示する仕組みになっています。

今回の絵文字では、 👨‍👩‍👧‍👦 (👨‍👩‍👧‍👦)で使われています。

大人2人と子ども2人の絵文字を使って、家族の絵文字を形成しています。

Unicode拡張書記素クラスタ境界

前置きが長くなりましたが、BreakIteratorでうまく扱えない文字で紹介した以下のような絵文字を分割するには
正規表現「Unicode拡張書記素クラスタ境界(\b{g})」を使います。

絵文字 数値文字参照
👆🏼 👆🏼
🇦🇿 🇦🇿
🏴󠁧󠁢󠁥󠁮󠁧󠁿 🏴󠁧󠁢󠁥󠁮󠁧󠁿
👨‍👩‍👧‍👦 👨‍👩‍👧‍👦

なお、Java 13以降を使う必要があります。

PatternクラスのJavadocに書かれていますね。

「Unicode拡張書記素クラスタ」は、書記素クラスタ・マッチャ\Xと対応する境界マッチャ\b{g}によってサポートされています。

Pattern (Java SE 17 & JDK 17)

なお、Unicode拡張書記素クラスタ自体はJava 9で導入されていました。

[JDK-7071819] To support Extended Grapheme Clusters in Regex - Java Bug System

Java 9のPatternクラスの時点で、すでに記載があります。

Pattern (Java SE 9 & JDK 9 )

Java 8の時点ではありませんでした。

Pattern (Java Platform SE 8)

Unicode拡張書記素クラスタについては、以下に記載があります。

ひとつまたはそれ以上のUnicode文字が、人が1文字として捉えるものを構成することがあります。文字というものに対して、コンピューター上での
曖昧さを避けるためのもので、これを「書記素クラスタ」と呼びます。

One or more Unicode characters may make up what the user thinks of as a character. To avoid ambiguity with the computer use of the term character, this is called a grapheme cluster.

Unicode regular expressions / Extended Unicode Support: Level 2 / Extended Grapheme Clusters and Character Classes with Strings

上記ドキュメントは、正規表現エンジンをUnicodeに適合させるためのガイドラインです。

いきなり「拡張(Extended)」とあるので、そうではないものがあるのでは?と思ったのですが、以前の書記素クラスタは「Legacy」と
呼ばれ、下位互換性のために残されているようです。

The Unicode Standard provides default algorithms for determining grapheme cluster boundaries, with two variants: legacy grapheme clusters and extended grapheme clusters. The most appropriate variant depends on the language and operation involved. However, the extended grapheme cluster boundaries are recommended for general processing, while the legacy grapheme cluster boundaries are maintained primarily for backwards compatibility with earlier versions of this specification.

Unicode Text Segmentation / Grapheme Cluster Boundaries

上記のドキュメントは、Unicodeテキストのセグメンテーションを決めるためのガイドラインです。その中に書記素クラスタについて
触れられている箇所があります。

サポートするUnicode拡張書記素クラスタのアップグレード

今回挙げた絵文字を扱うためにはJava 13以降を使う必要がある、と書いたのは以下のリリースノートに関連します。

JDK 13 Release Notes

Java 13でUnicode 12.1をサポートしましたが、

Bug ID: JDK-8221431 Support for Unicode 12.1

この時にUnicode拡張書記素クラスタのアップグレードを行っています。

[JDK-8222978] Upgrade the extended grapheme cluster support to the latest Unicode level. - Java Bug System

一連の修正コミット。

8221431: Support for Unicode 12.1 · openjdk/jdk17u@93fabcd · GitHub

この対応がポイントです。

その関連issueとして、Unicode拡張書記素クラスタ境界で複数の絵文字から成る「フラグ(旗)」の絵文字が、うまく扱えなかったという
事象が報告されていました。

[JDK-8209777] \b{g} in regexes fails to break between flag emoji - Java Bug System

今回は、Java 17でUnicode拡張書記素クラスタ境界を使って文字を分割してみたいと思います。

なお、動作確認時点でのPatternとGrapheme(Unicode拡張書記素クラスタに関するクラス)のソースコードは、こちら。

https://github.com/openjdk/jdk17u/blob/jdk-17.0.4+8/src/java.base/share/classes/java/util/regex/Pattern.java

https://github.com/openjdk/jdk17u/blob/jdk-17.0.4+8/src/java.base/share/classes/java/util/regex/Grapheme.java

環境

今回の環境は、こちらです。

$ java --version
openjdk 17.0.4 2022-07-19
OpenJDK Runtime Environment (build 17.0.4+8-Ubuntu-120.04)
OpenJDK 64-Bit Server VM (build 17.0.4+8-Ubuntu-120.04, mixed mode, sharing)


$ mvn --version
Apache Maven 3.8.6 (84538c9988a25aec085021c365c560670ad80f63)
Maven home: $HOME/.sdkman/candidates/maven/current
Java version: 17.0.4, vendor: Private Build, runtime: /usr/lib/jvm/java-17-openjdk-amd64
Default locale: ja_JP, platform encoding: UTF-8
OS name: "linux", version: "5.4.0-126-generic", arch: "amd64", family: "unix"

準備

Maven依存関係やプラグインの設定。

    <properties>
        <maven.compiler.source>17</maven.compiler.source>
        <maven.compiler.target>17</maven.compiler.target>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
    </properties>

    <dependencies>
        <dependency>
            <groupId>org.junit.jupiter</groupId>
            <artifactId>junit-jupiter</artifactId>
            <version>5.9.1</version>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>org.assertj</groupId>
            <artifactId>assertj-core</artifactId>
            <version>3.23.1</version>
            <scope>test</scope>
        </dependency>
    </dependencies>
    <build>
        <plugins>
            <plugin>
                <artifactId>maven-surefire-plugin</artifactId>
                <version>2.22.2</version>
            </plugin>
        </plugins>
    </build>

確認は、前エントリーと同様にテストコードで行うことにします。

テストコードの雛形。

src/test/java/CountCharTest.java

import java.text.BreakIterator;
import java.util.function.Function;
import java.util.stream.Collectors;

import org.junit.jupiter.api.Test;

import static org.assertj.core.api.Assertions.assertThat;

public class CountCharTest {

    // ここに、テストを書く
}

お題

今回は、BMPの範囲の文字、サロゲートペア、異体字セレクタを使った文字、絵文字、複数の絵文字からなる絵文字をそれぞれ数えてみます。

対象は以下にしました。前回のエントリーでの確認内容も入れています。

    @Test
    public void print() {
        String bmpString = "ç¾½";
        String surrogatePairString = "\uD867\uDE3D";  // U+29E3D
        String variationSelectorString = "飴\uDB40\uDD01";  // U+98F4 U+E0101
        String emojiString = "🍀";  // U+1F340
        String combinedEmojiString1 = "\uD83D\uDC46\uD83C\uDFFC";  // U+1F446 U+1F3FC
        String combinedEmojiString2 = "\uD83C\uDDE6\uD83C\uDDFF";  // U+1F1E6 U+1F1FF
        String combinedEmojiString3 = "\uD83C\uDFF4\uDB40\uDC67\uDB40\uDC62\uDB40\uDC65\uDB40\uDC6E\uDB40\uDC67\uDB40\uDC7F";  // U+1F3F4 U+E0067 U+E0062 U+E0065 U+E006E U+E0067 U+E007F
        String combinedEmojiString4 = "\uD83D\uDC68\u200D\uD83D\uDC69\u200D\uD83D\uDC67\u200D\uD83D\uDC66";  // U+1F468 U+200D U+1F469 U+200D U+1F467 U+200D U+1F466

        System.out.printf("BMP example: %s%n", bmpString);
        System.out.printf("SurrogatePair example: %s%n", surrogatePairString);
        System.out.printf("VariationSelector example: %s%n", variationSelectorString);
        System.out.printf("Emoji example: %s%n", emojiString);
        System.out.printf("Combined Emoji2 example: %s%n", combinedEmojiString1);
        System.out.printf("Combined Emoji2 example: %s%n", combinedEmojiString2);
        System.out.printf("Combined Emoji3 example: %s%n", combinedEmojiString3);
        System.out.printf("Combined Emoji4 example: %s%n", combinedEmojiString4);
    }

標準出力で確認するとこのようになりました。
※この箇所だけ、家族の絵文字がうまく貼れませんでした…

BMP example: ç¾½
SurrogatePair example: 𩸽
VariationSelector example: 飴󠄁
Emoji example: 🍀
Combined Emoji2 example: 👆🏼
Combined Emoji2 example: 🇦🇿
Combined Emoji3 example: 🏴󠁧󠁢󠁥
Combined Emoji4 example: 👨<200d>👩<200d>👧<200d>👦

Unicodeコードポイントで見ると、このようになります。

    @Test
    public void unicodeCodePoints() {
        String bmpString = "ç¾½";
        String surrogatePairString = "\uD867\uDE3D";  // U+29E3D
        String variationSelectorString = "飴\uDB40\uDD01";  // U+98F4 U+E0101
        String emojiString = "🍀";  // U+1F340
        String combinedEmojiString1 = "\uD83D\uDC46\uD83C\uDFFC";  // U+1F446 U+1F3FC
        String combinedEmojiString2 = "\uD83C\uDDE6\uD83C\uDDFF";  // U+1F1E6 U+1F1FF
        String combinedEmojiString3 = "\uD83C\uDFF4\uDB40\uDC67\uDB40\uDC62\uDB40\uDC65\uDB40\uDC6E\uDB40\uDC67\uDB40\uDC7F";  // U+1F3F4 U+E0067 U+E0062 U+E0065 U+E006E U+E0067 U+E007F
        String combinedEmojiString4 = "\uD83D\uDC68\u200D\uD83D\uDC69\u200D\uD83D\uDC67\u200D\uD83D\uDC66";  // U+1F468 U+200D U+1F469 U+200D U+1F467 U+200D U+1F466

        assertThat(bmpString.codePoints().mapToObj(v -> "0x" + Integer.toHexString(v)).collect(Collectors.toList()))
                .containsExactly("0x7fbd");
        assertThat(surrogatePairString.codePoints().mapToObj(v -> "0x" + Integer.toHexString(v)).collect(Collectors.toList()))
                .containsExactly("0x29e3d");
        assertThat(variationSelectorString.codePoints().mapToObj(v -> "0x" + Integer.toHexString(v)).collect(Collectors.toList()))
                .containsExactly("0x98f4", "0xe0101");
        assertThat(emojiString.codePoints().mapToObj(v -> "0x" + Integer.toHexString(v)).collect(Collectors.toList()))
                .containsExactly("0x1f340");
        assertThat(combinedEmojiString1.codePoints().mapToObj(v -> "0x" + Integer.toHexString(v)).collect(Collectors.toList()))
                .containsExactly("0x1f446", "0x1f3fc");
        assertThat(combinedEmojiString2.codePoints().mapToObj(v -> "0x" + Integer.toHexString(v)).collect(Collectors.toList()))
                .containsExactly("0x1f1e6", "0x1f1ff");
        assertThat(combinedEmojiString3.codePoints().mapToObj(v -> "0x" + Integer.toHexString(v)).collect(Collectors.toList()))
                .containsExactly("0x1f3f4", "0xe0067", "0xe0062", "0xe0065", "0xe006e", "0xe0067", "0xe007f");
        assertThat(combinedEmojiString4.codePoints().mapToObj(v -> "0x" + Integer.toHexString(v)).collect(Collectors.toList()))
                .containsExactly("0x1f468", "0x200d", "0x1f469", "0x200d", "0x1f467", "0x200d", "0x1f466");
    }

Unicode拡張書記素クラスタ境界を使う

では、Unicode拡張書記素クラスタ境界(\b{g})を使ってみます。

    @Test
    public void extendedGraphemeClusterRegex() {
        String bmpString = "ç¾½";
        String surrogatePairString = "\uD867\uDE3D";  // U+29E3D
        String variationSelectorString = "飴\uDB40\uDD01";  // U+98F4 U+E0101
        String emojiString = "🍀";  // U+1F340
        String combinedEmojiString1 = "\uD83D\uDC46\uD83C\uDFFC";  // U+1F446 U+1F3FC
        String combinedEmojiString2 = "\uD83C\uDDE6\uD83C\uDDFF";  // U+1F1E6 U+1F1FF
        String combinedEmojiString3 = "\uD83C\uDFF4\uDB40\uDC67\uDB40\uDC62\uDB40\uDC65\uDB40\uDC6E\uDB40\uDC67\uDB40\uDC7F";  // U+1F3F4 U+E0067 U+E0062 U+E0065 U+E006E U+E0067 U+E007F
        String combinedEmojiString4 = "\uD83D\uDC68\u200D\uD83D\uDC69\u200D\uD83D\uDC67\u200D\uD83D\uDC66";  // U+1F468 U+200D U+1F469 U+200D U+1F467 U+200D U+1F466

        assertThat(bmpString.split("\\b{g}").length).isEqualTo(1);
        assertThat(surrogatePairString.split("\\b{g}").length).isEqualTo(1);
        assertThat(variationSelectorString.split("\\b{g}").length).isEqualTo(1);
        assertThat(emojiString.split("\\b{g}").length).isEqualTo(1);
        assertThat(combinedEmojiString1.split("\\b{g}").length).isEqualTo(1);
        assertThat(combinedEmojiString2.split("\\b{g}").length).isEqualTo(1);
        assertThat(combinedEmojiString3.split("\\b{g}").length).isEqualTo(1);
        assertThat(combinedEmojiString4.split("\\b{g}").length).isEqualTo(1);
    }

すべて1文字になりました。

BreakIterator

BreakIteratorでも確認してみましょう。

    @Test
    public void breakIterator() {
        String bmpString = "ç¾½";
        String surrogatePairString = "\uD867\uDE3D";  // U+29E3D
        String variationSelectorString = "飴\uDB40\uDD01";  // U+98F4 U+E0101
        String emojiString = "🍀";  // U+1F340
        String combinedEmojiString1 = "\uD83D\uDC46\uD83C\uDFFC";  // U+1F446 U+1F3FC
        String combinedEmojiString2 = "\uD83C\uDDE6\uD83C\uDDFF";  // U+1F1E6 U+1F1FF
        String combinedEmojiString3 = "\uD83C\uDFF4\uDB40\uDC67\uDB40\uDC62\uDB40\uDC65\uDB40\uDC6E\uDB40\uDC67\uDB40\uDC7F";  // U+1F3F4 U+E0067 U+E0062 U+E0065 U+E006E U+E0067 U+E007F
        String combinedEmojiString4 = "\uD83D\uDC68\u200D\uD83D\uDC69\u200D\uD83D\uDC67\u200D\uD83D\uDC66";  // U+1F468 U+200D U+1F469 U+200D U+1F467 U+200D U+1F466

        Function<String, Integer> counter = w -> {
            BreakIterator iterator = BreakIterator.getCharacterInstance();
            iterator.setText(w);

            int count = 0;
            while (iterator.next() != BreakIterator.DONE) {
                count++;
            }

            return count;
        };

        assertThat(counter.apply(bmpString)).isEqualTo(1);
        assertThat(counter.apply(surrogatePairString)).isEqualTo(1);
        assertThat(counter.apply(variationSelectorString)).isEqualTo(1);
        assertThat(counter.apply(emojiString)).isEqualTo(1);
        assertThat(counter.apply(combinedEmojiString1)).isEqualTo(2);
        assertThat(counter.apply(combinedEmojiString2)).isEqualTo(2);
        assertThat(counter.apply(combinedEmojiString3)).isEqualTo(7);
        assertThat(counter.apply(combinedEmojiString4)).isEqualTo(7);
    }

こちらは、複数の絵文字から構成される絵文字が軒並みうまく扱えていません。

というわけで、Java 13以降であればUnicode拡張書記素クラスタ境界(\b{g})を使った方が良さそうです。

Java 11だとどうなるのか

ところで、Java 11だとUnicode拡張書記素クラスタ境界(\b{g})がどういう結果になるのか試してみることにしました。

素直にテストコードを書くと失敗したところで止まってしまうので、こんなコードを用意。

こちらは、文字数を表示します。

    @Test
    public void printExtendedGraphemeClusterRegexLength() {
        String bmpString = "ç¾½";
        String surrogatePairString = "\uD867\uDE3D";  // U+29E3D
        String variationSelectorString = "飴\uDB40\uDD01";  // U+98F4 U+E0101
        String emojiString = "🍀";  // U+1F340
        String combinedEmojiString1 = "\uD83D\uDC46\uD83C\uDFFC";  // U+1F446 U+1F3FC
        String combinedEmojiString2 = "\uD83C\uDDE6\uD83C\uDDFF";  // U+1F1E6 U+1F1FF
        String combinedEmojiString3 = "\uD83C\uDFF4\uDB40\uDC67\uDB40\uDC62\uDB40\uDC65\uDB40\uDC6E\uDB40\uDC67\uDB40\uDC7F";  // U+1F3F4 U+E0067 U+E0062 U+E0065 U+E006E U+E0067 U+E007F
        String combinedEmojiString4 = "\uD83D\uDC68\u200D\uD83D\uDC69\u200D\uD83D\uDC67\u200D\uD83D\uDC66";  // U+1F468 U+200D U+1F469 U+200D U+1F467 U+200D U+1F466

        System.out.printf("%s: splitted length = %d%n", bmpString, bmpString.split("\\b{g}").length);
        System.out.printf("%s: splitted length = %d%n", surrogatePairString, surrogatePairString.split("\\b{g}").length);
        System.out.printf("%s: splitted length = %d%n", variationSelectorString, variationSelectorString.split("\\b{g}").length);
        System.out.printf("%s: splitted length = %d%n", emojiString, emojiString.split("\\b{g}").length);
        System.out.printf("%s: splitted length = %d%n", combinedEmojiString1, combinedEmojiString1.split("\\b{g}").length);
        System.out.printf("%s: splitted length = %d%n", combinedEmojiString2, combinedEmojiString2.split("\\b{g}").length);
        System.out.printf("%s: splitted length = %d%n", combinedEmojiString3, combinedEmojiString3.split("\\b{g}").length);
        System.out.printf("%s: splitted length = %d%n", combinedEmojiString4, combinedEmojiString4.split("\\b{g}").length);
    }

こちらは、ここまでに登場した全文字をつなげて、どのように分割されるのかを表示します。

    @Test
    public void splitCharsUsingExtendedGraphemeClusterRegex() {
        String string = "羽\uD867\uDE3D飴\uDB40\uDD01🍀\uD83D\uDC46\uD83C\uDFFC\uD83C\uDDE6\uD83C\uDDFF\uD83C\uDFF4\uDB40\uDC67\uDB40\uDC62\uDB40\uDC65\uDB40\uDC6E\uDB40\uDC67\uDB40\uDC7F\uD83D\uDC68\u200D\uD83D\uDC69\u200D\uD83D\uDC67\u200D\uD83D\uDC66";

        for (String c : string.split("\\b{g}")) {
            int start = string.indexOf(c);
            int end = start + c.length();
            System.out.printf("extended grapheme cluster regex: char(%d, %d) = %s%n", start, end, c);
        }
    }

それぞれ、結果はこうなりました。

ç¾½: splitted length = 1
𩸽: splitted length = 1
飴󠄁: splitted length = 1
🍀: splitted length = 1
👆🏼: splitted length = 1
🇦🇿: splitted length = 1
🏴󠁧󠁢󠁥: splitted length = 1
👨‍👩‍👧‍👦: splitted length = 1


extended grapheme cluster regex: char(0, 1) = ç¾½
extended grapheme cluster regex: char(1, 3) = 𩸽
extended grapheme cluster regex: char(3, 6) = 飴󠄁
extended grapheme cluster regex: char(6, 8) = 🍀
extended grapheme cluster regex: char(8, 12) = 👆🏼
extended grapheme cluster regex: char(12, 16) = 🇦🇿
extended grapheme cluster regex: char(16, 30) = 🏴󠁧󠁢󠁥
extended grapheme cluster regex: char(30, 41) = 👨‍👩‍👧‍👦

ここで、Java 11に切り替え、

$ java --version
openjdk 11.0.16 2022-07-19
OpenJDK Runtime Environment (build 11.0.16+8-post-Ubuntu-0ubuntu120.04)
OpenJDK 64-Bit Server VM (build 11.0.16+8-post-Ubuntu-0ubuntu120.04, mixed mode, sharing)

pom.xmlでのコンパイルオプションも変更して再度実行してみます。

    <properties>
        <maven.compiler.source>11</maven.compiler.source>
        <maven.compiler.target>11</maven.compiler.target>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
    </properties>

すると、確かにおかしな結果になりました。

ç¾½: splitted length = 1
𩸽: splitted length = 1
飴󠄁: splitted length = 1
🍀: splitted length = 1
👆🏼: splitted length = 2
🇦🇿: splitted length = 1
🏴󠁧󠁢󠁥: splitted length = 7
👨‍👩‍👧‍👦: splitted length = 4


extended grapheme cluster regex: char(0, 1) = ç¾½
extended grapheme cluster regex: char(1, 3) = 𩸽
extended grapheme cluster regex: char(3, 6) = 飴󠄁
extended grapheme cluster regex: char(6, 8) = 🍀
extended grapheme cluster regex: char(8, 10) = 👆
extended grapheme cluster regex: char(10, 12) = 🏼
extended grapheme cluster regex: char(12, 16) = 🇦🇿
extended grapheme cluster regex: char(16, 18) = 🏴
extended grapheme cluster regex: char(18, 20) = 󠁧
extended grapheme cluster regex: char(20, 22) = 󠁢
extended grapheme cluster regex: char(22, 24) = 󠁥
extended grapheme cluster regex: char(24, 26) = 󠁮
extended grapheme cluster regex: char(18, 20) = 󠁧
extended grapheme cluster regex: char(28, 30) = 󠁿
extended grapheme cluster regex: char(30, 33) = 👨‍
extended grapheme cluster regex: char(33, 36) = 👩‍
extended grapheme cluster regex: char(36, 39) = 👧‍
extended grapheme cluster regex: char(39, 41) = 👦

確かに、以前のバージョンではうまく機能しないようですね。

その他

ここから先は、Javaのバージョンで結果が変わらない方法で見ていきます。内容的には、以前のエントリーで書いたものと同じですが。

String#length

今回のお題だと、String#lengthでは文字数をうまく数えられないものばかりになります。

    @Test
    public void stringLength() {
        String bmpString = "ç¾½";
        String surrogatePairString = "\uD867\uDE3D";  // U+29E3D
        String variationSelectorString = "飴\uDB40\uDD01";  // U+98F4 U+E0101
        String emojiString = "🍀";  // U+1F340
        String combinedEmojiString1 = "\uD83D\uDC46\uD83C\uDFFC";  // U+1F446 U+1F3FC
        String combinedEmojiString2 = "\uD83C\uDDE6\uD83C\uDDFF";  // U+1F1E6 U+1F1FF
        String combinedEmojiString3 = "\uD83C\uDFF4\uDB40\uDC67\uDB40\uDC62\uDB40\uDC65\uDB40\uDC6E\uDB40\uDC67\uDB40\uDC7F";  // U+1F3F4 U+E0067 U+E0062 U+E0065 U+E006E U+E0067 U+E007F
        String combinedEmojiString4 = "\uD83D\uDC68\u200D\uD83D\uDC69\u200D\uD83D\uDC67\u200D\uD83D\uDC66";  // U+1F468 U+200D U+1F469 U+200D U+1F467 U+200D U+1F466

        assertThat(bmpString.length()).isEqualTo(1);
        assertThat(surrogatePairString.length()).isEqualTo(2);
        assertThat(variationSelectorString.length()).isEqualTo(3);
        assertThat(emojiString.length()).isEqualTo(2);
        assertThat(combinedEmojiString1.length()).isEqualTo(4);
        assertThat(combinedEmojiString2.length()).isEqualTo(4);
        assertThat(combinedEmojiString3.length()).isEqualTo(14);
        assertThat(combinedEmojiString4.length()).isEqualTo(11);
    }
String#codePointCount

String#codePointCountは異体字セレクタ以外は1になりました。絵文字もうまく扱えるんですね。

    @Test
    public void codePointCount() {
        String bmpString = "ç¾½";
        String surrogatePairString = "\uD867\uDE3D";  // U+29E3D
        String variationSelectorString = "飴\uDB40\uDD01";  // U+98F4 U+E0101
        String emojiString = "🍀";  // U+1F340
        String combinedEmojiString1 = "\uD83D\uDC46\uD83C\uDFFC";  // U+1F446 U+1F3FC
        String combinedEmojiString2 = "\uD83C\uDDE6\uD83C\uDDFF";  // U+1F1E6 U+1F1FF
        String combinedEmojiString3 = "\uD83C\uDFF4\uDB40\uDC67\uDB40\uDC62\uDB40\uDC65\uDB40\uDC6E\uDB40\uDC67\uDB40\uDC7F";  // U+1F3F4 U+E0067 U+E0062 U+E0065 U+E006E U+E0067 U+E007F
        String combinedEmojiString4 = "\uD83D\uDC68\u200D\uD83D\uDC69\u200D\uD83D\uDC67\u200D\uD83D\uDC66";  // U+1F468 U+200D U+1F469 U+200D U+1F467 U+200D U+1F466

        assertThat(bmpString.codePointCount(0, bmpString.length())).isEqualTo(1);
        assertThat(surrogatePairString.codePointCount(0, surrogatePairString.length())).isEqualTo(1);
        assertThat(variationSelectorString.codePointCount(0, variationSelectorString.length())).isEqualTo(2);
        assertThat(emojiString.codePointCount(0, emojiString.length())).isEqualTo(1);
        assertThat(combinedEmojiString1.codePointCount(0, emojiString.length())).isEqualTo(1);
        assertThat(combinedEmojiString2.codePointCount(0, emojiString.length())).isEqualTo(1);
        assertThat(combinedEmojiString3.codePointCount(0, emojiString.length())).isEqualTo(1);
        assertThat(combinedEmojiString4.codePointCount(0, emojiString.length())).isEqualTo(1);
    }
BreakIteratorで文字を分割

BreakIteratorを使って文字を数えるコードはすでに載せましたが、どんな感じの分割のされ方をしていたか確認してみましょう。

Unicode拡張書記素クラスタ境界(\b{g})を使って、今回扱ったすべての文字をつなげて、分割するコードをBreakIteratorを使って
書いてみます。

    @Test
    public void splitCharsUsingBreakIterator() {
        String string = "羽\uD867\uDE3D飴\uDB40\uDD01🍀\uD83D\uDC46\uD83C\uDFFC\uD83C\uDDE6\uD83C\uDDFF\uD83C\uDFF4\uDB40\uDC67\uDB40\uDC62\uDB40\uDC65\uDB40\uDC6E\uDB40\uDC67\uDB40\uDC7F\uD83D\uDC68\u200D\uD83D\uDC69\u200D\uD83D\uDC67\u200D\uD83D\uDC66";

        BreakIterator iterator = BreakIterator.getCharacterInstance();
        iterator.setText(string);

        for (int start = iterator.first(), end = iterator.next();
             end != BreakIterator.DONE; start = end, end = iterator.next()) {
            String c = string.substring(start, end);
            System.out.printf("break iterator: char(%d, %d) = %s%n", start, end, c);
        }
    }

結果。

break iterator: char(0, 1) = ç¾½
break iterator: char(1, 3) = 𩸽
break iterator: char(3, 6) = 飴󠄁
break iterator: char(6, 8) = 🍀
break iterator: char(8, 10) = 👆
break iterator: char(10, 12) = 🏼
break iterator: char(12, 14) = 🇦
break iterator: char(14, 16) = 🇿
break iterator: char(16, 18) = 🏴
break iterator: char(18, 20) = 󠁧
break iterator: char(20, 22) = 󠁢
break iterator: char(22, 24) = 󠁥
break iterator: char(24, 26) = 󠁮
break iterator: char(26, 28) = 󠁧
break iterator: char(28, 30) = 󠁿
break iterator: char(30, 32) = 👨
break iterator: char(32, 33) = ‍
break iterator: char(33, 35) = 👩
break iterator: char(35, 36) = ‍
break iterator: char(36, 38) = 👧
break iterator: char(38, 39) = ‍
break iterator: char(39, 41) = 👦

やっぱり、各絵文字がバラバラに切り出されてしまいました。

代替手段

Java 13以前の環境で、Unicode拡張書記素クラスタ境界(\b{g})と同じように文字を数えたい(分割したい)場合は、ICU4JのBreakIteratorを
使えばよいみたいです。

ICU4J - ICU Documentation

BreakIterator (ICU4J 71.1)

まとめ

これまで先送りにしていた、Unicode拡張書記素クラスタ境界(\b{g})を使った文字の数え方(分割の仕方)を試してみました。

使い方そのものよりも、絵文字に関する情報を調べる方が大変でしたね…。