Lazy Diary @ Hatena Blog

PowerShell / Java / miscellaneous things about software development, Tips & Gochas. CC BY-SA 4.0/Apache License 2.0

java.text.BreakIteratorによる文字数(grapheme)カウント

JIS X 0213など、シフトJISマイクロソフト コードページ932以外の文字を業務プログラムで扱う場合には、入力された文字列を証明書などに確実に収めるため、文字数を正しくカウントする必要があります。

JIS X 0213では複数のコードポイントで1文字を表す文字(合字)があります。このような場合、見た目の「1文字」を「grapheme」(書記素)と呼びます。そのため、証明書などへの印刷において、書記素ひとつを「1文字」としてカウントする必要があるわけです。

さて、以下のサイト「文字数をカウントする7つの方法」では、java.text.BreakIteratorを使って書記素をカウントする方法を示しています。どうやらLINEで使われているカウントの方法らしい。 engineering.linecorp.com

ところが調べてみると、業務プログラムでそのまま使うには、コーナーケースをカバーしきれていないようです……

https://repl.it/@satob/BreakIteratorTest

import java.text.BreakIterator;

class Main {
  public static void main(String[] args) {
    // (a) 一般的な非漢字「あ」
    //     (1書記素=1ユニット=1コードポイント)
    System.out.println("\u3042");
    // 1書記素とカウントすべきところ、正しく1書記素とカウントされる
    System.out.println(getGraphemeLength("\u3042")); // -> 1

    // (b) サロゲートペア「𠮷」
    //     (1書記素=2ユニット=1コードポイント)
    System.out.println("\ud842\udfb7");
    // 1書記素とカウントすべきところ、正しく1書記素とカウントされる
    System.out.println(getGraphemeLength("\ud842\udfb7")); // -> 1

    // (c) 異体字セレクタつき文字「侮」
    //     (1書記素=2ユニット=2コードポイント)
    //     ただし2コードポイント目は異体字セレクタ(文字ではない)。
    System.out.println("\u4fae\ufe00");
    // 1書記素とカウントすべきところ、正しく1書記素とカウントされる
    System.out.println(getGraphemeLength("\u4fae\ufe00")); // -> 1

    // (d) JIS X 0213合字「˥˩」
    //     (1書記素=2ユニット=2コードポイント)
    //     1文字目も2文字目も単独で書記素として意味をなす。
    System.out.println("\u02e5\u02e9");
    // 1書記素とカウントすべきところ、誤って2書記素とカウントされる
    System.out.println(getGraphemeLength("\u02e5\u02e9")); // -> 2

    // (e) JIS X 0213合字「カ゚」
    //     (1書記素=2ユニット=2コードポイント)
    //     2コードポイント目は合字用文字で、
    //     JIS X 0213非漢字に定義されている正しい合字の組み合わせ。
    System.out.println("\u30ab\u309a");
    // 1書記素とカウントすべきところ、正しく1書記素とカウントされる
    System.out.println(getGraphemeLength("\u30ab\u309a")); // -> 1

    // (f) (e)の1コードポイント目と2コードポイント目を逆にしたもの
    //     JIS X 0213非漢字に定義されていない組み合わせ。
    //     2ユニット、2コードポイントだがJIS X 0213の文字としては不当。
    //     1コードポイント目は合字用文字だが、組み合わせる文字がないので
    //     表示時には合わせて2書記素ぶんの幅をとる。
    System.out.println("\u309a\u30ab");
    // 正しいは判断つかないが、ともかく2書記素とカウントされる
    System.out.println(getGraphemeLength("\u309a\u30ab")); // -> 2

    // (g) JIS X 0213にない合字「『ま』にマル」
    //     2ユニット、2コードポイントだがJIS X 0213の文字としては不当。
    //     フォントの実装上1書記素の幅で表示されるが、JIS X 0213の規格としては
    //     2書記素ぶんになるのでは?
    System.out.println("\u307e\u309a");
    // 正しいは判断つかないが、ともかく1書記素とカウントされる
    System.out.println(getGraphemeLength("\u307e\u309a")); // -> 1
    
    // (h) (e)の2コードポイント目だけを単独でカウント
    //     1ユニット、1コードポイントだがJIS X 0213の文字としては不当。
    //     (f)の11コードポイント目と同様、表示時には1書記素ぶんの幅をとる。
    System.out.println("\u309a");
    // 正しいは判断つかないが、ともかく1書記素とカウントされる
    System.out.println(getGraphemeLength("\u309a")); // -> 1

    // (i) (おまけ)ZWJを使った絵文字「👨<200d>👩<200d>👦」
    //     8ユニット、5コードポイント、1書記素。
    System.out.println("\uD83D\uDC68\u200D\uD83D\uDC69\u200D\uD83D\uDC66");
    System.out.println(getGraphemeLength("\uD83D\uDC68\u200D\uD83D\uDC69\u200D\uD83D\uDC66")); // -> 5
  }

  public static int getGraphemeLength(String value) {
    BreakIterator it = BreakIterator.getCharacterInstance();
    it.setText(value);
    int count = 0;
    while (it.next() != BreakIterator.DONE) {
        count++;
    }
    return count;
  }
}

まとめると、JIS X 0213の範囲では以下のような挙動をするようです。2.がキツいな……

  1. サロゲートペア、IVSは正しく処理されている
  2. 1コードポイント目・2コードポイント目とも書記素として正当な組み合わせの合字が2書記素とカウントされてしまう
  3. JIS X 0213として不正な合字でも無理やりカウントしてしまう
  4. (おまけ)ZWJは1書記素としてカウントされてしまう

対策としては以下のような感じかと思います。

  • 文字列長チェックの前に、JIS X 0213として不正なコードポイントの並びがないか確認する
  • 1コードポイント目・2コードポイント目とも書記素として正当な組み合わせの合字が含まれている場合、文字列長から引き算する