はじめに
近年、漢字の異体字セレクターや絵文字の肌色修飾子や男女の性別セレクターなど、複数のコードポイントであらわされる拡張書記素クラスターを扱う機会が増えています。ハングル、梵字を含むインド系文字、アラビア文字など、アジア諸言語を扱う場合にも拡張書記素クラスターの概念は必要です。
countElements や for-in ループは拡張書記素クラスターに対応しており、部分文字列を取り出すときに、拡張書記素クラスターを壊してしまう可能性を減らします。
一方で、Unicode の仕様では、拡張書記素クラスターのサイズの上限はないので、描画レンダリングエンジンなどの文字列を扱うライブラリの実装が対応していない場合、無限ループやバッファーオーバーフローにより、アプリケーションや OS がクラッシュする可能性があります。
実際の例
インド系の結合文字を大量に使うことで、文字化けのような現象を起こして、画面を見づらくしたり、不快感を与えるイタズラができます。実際に文字列がどのように表示されるのかはニコニコ百科のたすけての記事やこの記事、stackoverflow の質問を御覧ください。
CoreText が大量の結合文字を適切に扱えずクラッシュする問題は Death of Unicode と呼ばれます (参考)。
Apple は iOS 8.4.1 で対応したことを述べています (「特定種の Unicode 文字を受信するとデバイスが再起動する問題を修正」)。クラッシュの再現動画が YouTube で公開されています。
コードを試す
異体字を例に取り組んでみましょう。葛飾区の「カツ」の異体字 (U+845B U+E0101) は1文字として数えられます。
var str = "葛\u{E0101}飾区"
println(3 == countElements(str))
この異体字を少しいじってみましょう。異体字セレクター (U+E0101) をたくさん繰り返した文字列をつくって、文字を数えてみましょう。次のようにたくさんの異体字セレクターが使われているにも関わらず、1つの書記素としてカウントされます。つまり、開発者が想定していない巨大な文字列の投稿を受け入れてしまう可能性があります。
var str = "葛" + String(count: 10000, repeatedValue: Character("\u{E0101}"))
println(1 == countElements(str))
for-in ループでも同じ結果になります。
var str = "葛" + String(count: 10000, repeatedValue: Character("\u{E0101}"))
var length = 0
for (index, c) in enumerate(str) {
++length
}
println(1 == length)
正規表現の \X でも同じ結果になります。
import Foundation
func numberOfMatchesInGrapheme(str: String) -> Int {
let regex = NSRegularExpression(
pattern: "\\X",
options: nil,
error: nil
)!
return regex.numberOfMatchesInString(
str,
options: nil,
range: NSMakeRange(0, str.utf16Count)
)
}
var str = "葛" + String(count: 10000, repeatedValue: Character("\u{E0101}"))
println(1 == numberOfMatchesInGrapheme(str))
コードポイント、バイト単位で数える
文字数バリデーションがすり抜けされないようにするための対策はコードポイント単位もしくはバイト単位で数えることです。そのために unicodeScalars もしくは utf8 プロパティを使います。
var str = "葛" + String(count: 10000, repeatedValue: Character("\u{E0101}"))
println(10001 == countElements(str.unicodeScalars))
println(40003 == countElements(str.utf8))
文字列の一部を取り出す場合にもコードポイント、バイト単位で数える
文字列の一部を取り出す際にも、書記素クラスター単位だけでなく、コードポイント単位、バイト単位で取り出すか使いわける必要があります。書記素クラスター単位では、冒頭から10文字を取り出したつもりが、実際には 1GB の文字列を取り出してしまうという可能性があるからです。
次のコードはコードポイント、バイトを上限としつつ、書記素クラスターを壊さないように文字列の一部を取り出しています。
func truncate(str: String, length: Int) -> String {
var ret = ""
for (index, c) in enumerate(str) {
if (index >= length ) {
break
}
ret += String(c)
}
return ret
}
func truncate_by_codepoints(str: String, length: Int) -> String {
var ret = ""
var ret_size = 0
var buf = ""
var buf_size = 0
for c in str {
buf = String(c)
buf_size = countElements(buf.unicodeScalars)
if (buf_size + ret_size > length) {
break
}
ret += buf
ret_size += buf_size
}
return ret
}
func truncate_by_bytes(str: String, length: Int) -> String {
var ret = ""
var ret_size = 0
var buf = ""
var buf_size = 0
for c in str {
buf = String(c)
buf_size = countElements(buf.utf8)
if (buf_size + ret_size > length) {
break
}
ret += buf
ret_size += buf_size
}
return ret
}
var str = "葛\u{E0101}飾区"
println("葛\u{E0101}" == truncate(str, 1))
println("葛\u{E0101}" == truncate_by_codepoints(str, 2))
println("葛\u{E0101}" == truncate_by_bytes(str, 7))
拡張書記素クラスターのサイズの上限を設定する
現実的な拡張書記素クラスターを構成する要素の数の上限をいくつか示します。
最初に挙げる上限値の例は30前後です。これは Unicode の仕様にある Stream-Safe テキストフォーマットを根拠としています。
次に挙げる上限値の例は18です。これは U+FDFA (ARABIC LIGATURE SALLALLAHOU ALAYHE WASALLAM) に NFKD を適用すると18文字に分解されることを根拠としています。
正規化を考えないのであれば、9が挙げられます。これはチベット語の「ཧྐྵྨླྺྼྻྂ」(Hakṣhmalawarayaṁ)が9文字(U+0F67 U+0F90 U+0FB5 U+0FA8 U+0FB3 U+0FBA U+0FBC U+0FBB U+0F82) であることを根拠としています。
日本語の NFD (濁点つきかな)や異体字だけしか考えないのであれば、2という数値もあり得るでしょう。
次のコードの例では、書記素クラスターを構成するコードポイントの数が想定する数値以内に収まっていれば、true を返し、そうでなければ false を返しています。
func grapheme_validate_size(str: String, size: Int = 9) -> Bool {
var buf = ""
for c in str {
buf = String(c)
if countElements(buf.unicodeScalars) > size {
return false
}
}
return true
}
var str = "葛" + String(count: 9, repeatedValue: Character("\u{E0101}"))
println(true == grapheme_validate_size("葛飾区"))
println(false == grapheme_validate_size(str))