文字数をカウントする7つの方法

こんにちは、LINE+開発室のパク・サンジン(SJ)です。

今回は、文字数をカウントする方法についてお話したいと思います。LINEサービスではプロフィール名やグループ名、ひとことなど様々なところで文字数をカウントしていますが、画面で文字が短すぎたり長すぎないようにし、ストレージ容量を正しく割り当てるためには、文字数を正確にカウントすることがとても重要です。特に、LINEは全世界で使っているサービスなだけに他言語の文字数も正確にカウントできなければなりません。 ある日、BTS(Bug Track System)のプロフィール名にemojiを入力すると1字が2字で表示されるといった、文字数が正確にカウントされないという課題が登録されました。emojiとは、日本で使い始めたもので、今ではUnicode標準に含まれ世界的に広く使われるようになった絵文字セットのことです。最初は、単純にSurrogateカウントエラーの問題だと思い分析し始めました。Surrogateとは、UTF-16エンコードを16ビット以上に拡張させる文字セットのことですが、emojiにはSurrogateで表現する文字があるからです。しかしよく見ると、実際にはSurrogateとは関係のない2文字が入力されていることが確認できました。ということは、「emojiの後ろに共通的に追加されるcharacterがあるのでは」と思い、そのルールを探し出して例外処理をしようと考えていました。が、さらに他の課題が登録されてきました。

「タイ文字が正確にカウントできません。」

調査してみると、タイ文字だけでなくアラビア文字、インド文字でも似たような現象が発生していました。 タイは、LINEユーザーの多い重要な国のひとつです。人口2位のインドも重要な対象国ですし、アラブ地域もイランをはじめLINEユーザーの多い重要国が多いため、この問題を根本的に解決する必要がありました。最初に発見したことは、タイ文字(ภาษาไทย)、アラビア文字(العربية)、デーヴァナーガリー文字(देवनागरी、ヒンディー語)の共通点が組合せ型の文字だということです。こうやって組合せ型文字のUnicode標準などについて勉強しながら気づいたことは、文字数をカウントする簡単なことさえグローバルサービスになると思ったより簡単ではない、ということでした。

具体的な内容

問: ““の文字数は?
答: 「文字」の定義によって異なります。

  1. Bytes : 8ビット。メモリ、またはストレージ上でUnicode stringが何バイトを占めるかはエンコードによって異なります。
  2. Code Units : textエンコードにおいて、処理のためにひとつの単位を表現できる最小限のビットの組合せ。例えば、1 code unitは、UTF-8は1バイト、UTF-16は2バイト、UTF-32は4バイトです。
  3. Code Points : Unicode character。Unicode space上のひとつのinteger値で、現在はU+0000~U+10FFFFの間の値のうちのひとつです。
  4. Grapheme clusters : ユーザーが認識するひとつの文字。1 grapheme clusterは複数のcode pointで構成されます。

「文字数」の定義とカウントを求める方法

  • Grapheme
    • ユーザーが認識する文字。文字体系で表現できる文字の最小単位。1 Graphemeは、N code pointで構成されている。
    • 例 : A 각 image2015-1-27 12_35_56 image2015-1-27 1_41_28
    • カウントを求める方法
public static int getGraphemeLength(String value) {
    BreakIterator it = BreakIterator.getCharacterInstance(); 
    it.setText(value); 
    int count = 0; 
    while (it.next() != BreakIterator.DONE) { 
        count++; 
    }
    return count;
}
  • Code Point
    • Unicode codespace上で0~10FFFFの間の値を持つ文字。
    • 例 : U+AC01
    • カウントを求める方法
 String.codePointCount() 

  • UTF-16BE
    • 各code pointを2、または4バイト(Big Endian)で表現するマルチバイトエンコード方式。Javaのprimitive ‘char’と1:1でマッピングして表現できる。Code pointをU+10000~U+10FFFFでエンコードするためには、4バイト(2 code unit)のhigh/low surrogateが必要である。
    • 例 : 0xAC01
    • カウントを求める方法
String.length() 
(code unit count) 
  • UTF-8
    • 各code pointを1から4バイト長の符号のないバイトシーケンスに割り当てるUnicodeエンコード方式。
    • 例 : 0xEA,0xB0,0x81,0xF0,0x9F,0x85,0xB1
    • カウントを求める方法
String.getBytes().length 
String.getBytes("UTF-8").length 
(byte count) 
  • CESU-8
    • 各code pointを1、2、3または6バイト長の符号のないバイトシーケンスに割り当てるUnicodeエンコード方式。UTF-8と同様に、範囲はU+0000~U+FFFFであるが、U+10000~U+10FFFFの間のcode pointは4バイトの代わりに3(high surrogate)+3(low surrogate)=6バイトでエンコードされている。
    • 例 : 0xED,0xA0,0xBC,0xED,0xB5,0xB1
    • カウントを求める方法
public static int getCESU8Length(String str) {
    int strlen = str.length(), utflen = 0, c = 0;
    for (int i = 0; i < strlen; i++) {
        c = str.charAt(i); 
        if ((c >= 0x0000) && (c <= 0x007F)) utflen++;
        else if (c > 0x07FF) utflen += 3;
        else utflen += 2; 
    } 
    return utflen; 
} 
  • Modified UTF-8
    • 変更されたUTF-8は、CESU-8エンコード方式でnull(U+0000)を0xC0、0x80でエンコードする追加のルールがある。(Java serialization、class fileなどでのみ使用する)
    • 例 : 0xED,0xA0,0xBC,0xED,0xB5,0xB1,0XC0,0x80
    • カウントを求める方法
public static int getModifiedUTF8Length(String str) { 
    int strlen = str.length(), utflen = 0, c = 0; 
    for (int i = 0; i < strlen; i++) { 
        c = str.charAt(i); 
        if ((c >= 0x0001) && (c <= 0x007F)) utflen++;
        else if (c > 0x07FF) utflen += 3;
        else utflen += 2; 
    } 
    return utflen; 
} 

GEMINIは、code pointでU+264Aであり、UTF-8で3バイトでエンコードされます。 GEMINIは、code pointでU+264Aであり、UTF-8で3バイトでエンコードされます。
GEMINI characterを実際にiPhoneなどで入力すると、emoji/text styleを選択する Variation-Selector character(VS15)が後ろに付いて2 code pointで表現されます。 GEMINI characterを実際にiPhoneなどで入力すると、emoji/text styleを選択する Variation-Selector character(VS15)が後ろに付いて2 code pointで表現されます。
別のemojiです。base characterであるU+1F171は、16ビットを超える領域に定義されているため、UTF-16でエンコードするとhigh/low surrogateの4バイトでエンコードされ、UTF-8で4バイト、CESU-8で6バイトでエンコードされます。 別のemojiです。base characterであるU+1F171は、16ビットを超える領域に定義されているため、UTF-16でエンコードするとhigh/low surrogateの4バイトでエンコードされ、UTF-8で4バイト、CESU-8で6バイトでエンコードされます。
上記のように3 code pointで表現されるemojiもあります。 上記のように3 code pointで表現されるemojiもあります。
デーヴァナーガリー文字の場合、1文字が4 code pointで表現される場合もあります。アラビア文字、タイ文字なども通常、複数のcode pointが1文字を表現します。 デーヴァナーガリー文字の場合、1文字が4 code pointで表現される場合もあります。アラビア文字、タイ文字なども通常、複数のcode pointが1文字を表現します。

ハングル(韓国語の文字)や一部のラテン系文字なども組合せ型の表現式があります(ハングルの字母、発音区別符号など) 例えば、「각」(U+AC01)は「ㄱㅏㄱ」(U+1100, U+1161, U+11A8)とも表現できますが、それぞれNFC(각)、NFD(ㄱㅏㄱ)形式といいます。Unicodeを扱うプログラムでは、2種の文字を同じものとして取り扱い、このような形式の間の変換のためにUnicode normalizationを行います。ハングル(現代のハングル)は、NFCで表現するとすべて1 code pointで表現されますが、ハングル古語(昔のハングル)、デーヴァナーガリー、アラビア文字、タイ文字などはNFC形式でも複数のcode pointを必要とします。(上記の例にあげたdevanagari kshiもすでにNFC形式です。) OSによってNFCを使うことも、NFDを使うこともありますが、MAC OSの場合はUnicodeファイルパスを扱うときに内部的にNFDを使用するため圧縮ファイル内のハングルなどのファイル名がWindowsなどで正確に表示されないこともあります。

しかし、どのケースにおいてもGrapheme clusterは、同様に1としてカウントされます。したがって、ユーザーが認識する文字数をすべての場合に正確にカウントするには、code unitやcode pointではなく、Grapheme clusterカウントを使用しなければなりません。下記は、言語ごとにgraphemeカウントを求める方法です。最新の言語ではもう少し親切なAPIをサポートしていることもあります。

言語ごとにgraphemeカウントを求める方法

  • Java
public static int getGraphemeLength(String value) {
    BreakIterator it = BreakIterator.getCharacterInstance();
    it.setText(value);
    int count = 0;
    while (it.next() != BreakIterator.DONE) {
        count++;
    }
    return count;
}
  • C++
int getGraphemeLength(const UnicodeString &amp;str) {
    UErrorCode err = U_ZERO_ERROR;
    std::unique_ptr<BreakIterator> iter(
        BreakIterator::createCharacterInstance(Locale::getDefault(), err));
    assert(U_SUCCESS(err));
    iter->setText(str);
    int count = 0;
    while(iter->next() != BreakIterator::DONE) ++count;
    return count;
}
  • Go
func grLen(s string) int {
    if len(s) == 0 {
        return 0
    }
    gr := 1
    _, s1 := utf8.DecodeRuneInString(s)
    for _, r := range s[s1:] {
        if !unicode.Is(unicode.Mn, r) {
            gr++
        }
    }
    return gr
}
  • Perl
say 'møøse'.graphs;
  • PHP
 $length = grapheme_strlen('Hello, world!') 
  • Swift
countElements(str)

Javaの primitive ‘char’はどうして1 graphemeでもなく、1 code pointでもないUTF-16エンコードの1 code unitに対応されるように設計されたのでしょうか? Javaの設計当時、Unicodeはすべてのcode pointが16ビットに定義されていたからです。

「16ビットで世の中のすべての文字を表現する。」という概念は、Unicode設計者が気に入っていたUnicodeの設計原則でもあります。(しかし、Javaが発表されてから程なくしてUnicodeは16ビットを超え拡張されます。現在、Unicode 7.0ではU+10FFFF、すなわち17*65536=1,114,112まで定義されています) 一方、MySQLやOracleの’utf8′ charsetは、実際にはUTF-8ではなくCESU-8も同然で、UTF-8より多くの空間を必要とする場合もあります。UTF-8エンコードを使用するには、’AL32UTF8′(oracle)、または’utf8mb4′(mysql) charsetを使用しなければなりません。最新の言語であるSwiftの場合、Character typeがはじめから1 Graphemeを表現するよう定義されています。

1 graphemeを保存するための空間は、4 code point、4 ‘char’またはUTF-8の場合は12バイト程度は割り当てておく必要があります。1 graphemeに最大で必要なcode point数は、各言語別automataとwriting systemまで関連している複雑な問題です。このため、Unicode標準でも取り扱っていません。

最初の質問に戻って、答えましょう。 は、6 grapheme、13 code point、UTF-8でエンコードすると36バイトとしてカウントされます。

Related Post