SubwayTooter-Android-App/app/src/main/java/jp/juggler/subwaytooter/util/EmojiDecoder.kt

315 lines
9.6 KiB
Kotlin
Raw Normal View History

package jp.juggler.subwaytooter.util
import android.content.Context
import android.support.annotation.DrawableRes
import android.text.Spannable
import android.text.SpannableStringBuilder
import android.text.Spanned
import jp.juggler.emoji.EmojiMap201709
import java.util.ArrayList
import java.util.regex.Pattern
import jp.juggler.subwaytooter.App1
import jp.juggler.subwaytooter.R
import jp.juggler.subwaytooter.api.entity.NicoProfileEmoji
import jp.juggler.subwaytooter.span.EmojiImageSpan
import jp.juggler.subwaytooter.span.HighlightSpan
import jp.juggler.subwaytooter.span.NetworkEmojiSpan
import jp.juggler.subwaytooter.table.HighlightWord
object EmojiDecoder {
private val rangeShortcodeAlphabetUpper = IntRange('A'.toInt(), 'Z'.toInt())
private val rangeShortcodeAlphabetLower = IntRange('a'.toInt(), 'z'.toInt())
private val rangeShortcodeNumber = IntRange('0'.toInt(), '9'.toInt())
private const val intCharMinus = '-'.toInt()
private const val intCharPlus = '+'.toInt()
private const val intCharUnder = '_'.toInt()
private const val intCharAt = '@'.toInt()
private const val intCharColon = ':'.toInt()
internal interface ShortCodeSplitterCallback {
fun onString(part : String)
fun onShortCode(part : String, name : String)
}
private fun isShortCodeCharacter(cp : Int) : Boolean {
return when(cp) {
in rangeShortcodeAlphabetUpper,
in rangeShortcodeAlphabetLower,
in rangeShortcodeNumber,
intCharMinus,
intCharPlus,
intCharUnder,
intCharAt -> true
else -> false
}
}
private val reNicoru = Pattern.compile("\\Anicoru\\d*\\z", Pattern.CASE_INSENSITIVE)
private val reHohoemi = Pattern.compile("\\Ahohoemi\\d*\\z", Pattern.CASE_INSENSITIVE)
private class DecodeEnv internal constructor(internal val context : Context, internal val options : DecodeOptions) {
internal val sb = SpannableStringBuilder()
internal var normal_char_start = - 1
internal fun closeNormalText() {
if(normal_char_start != - 1) {
val end = sb.length
applyHighlight(normal_char_start, end)
normal_char_start = - 1
}
}
private fun applyHighlight(start : Int, end : Int) {
if(options.highlight_trie != null) {
val list = options.highlight_trie !!.matchList(sb, start, end)
if(list != null) {
for(range in list) {
val word = HighlightWord.load(range.word)
if(word != null) {
options.hasHighlight = true
sb.setSpan(HighlightSpan(word.color_fg, word.color_bg), range.start, range.end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
if(word.sound_type != HighlightWord.SOUND_TYPE_NONE) {
options.highlight_sound = word
}
}
}
}
}
}
internal fun addUnicodeString(s : String) {
var i = 0
val end = s.length
while(i < end) {
val remain = end - i
var emoji : String? = null
var image_id : Int? = null
for(j in EmojiMap201709.utf16_max_length downTo 1) {
if(j > remain) continue
val check = s.substring(i, i + j)
image_id = EmojiMap201709.sUTF16ToImageId[check]
if(image_id != null) {
if(j < remain && s[i + j].toInt() == 0xFE0E) {
// 絵文字バリエーション・シーケンスEVSのU+FE0EVS-15が直後にある場合
// その文字を絵文字化しない
emoji = s.substring(i, i + j + 1)
image_id = 0
} else {
emoji = check
}
break
}
}
if(image_id != null) {
if(image_id == 0) {
// 絵文字バリエーション・シーケンスEVSのU+FE0EVS-15が直後にある場合
// その文字を絵文字化しない
if(normal_char_start == - 1) {
normal_char_start = sb.length
}
sb.append(emoji)
} else {
addImageSpan(emoji, image_id)
}
i += emoji !!.length
continue
}
if(normal_char_start == - 1) {
normal_char_start = sb.length
}
val length = Character.charCount(s.codePointAt(i))
if(length == 1) {
sb.append(s[i])
++ i
} else {
sb.append(s.substring(i, i + length))
i += length
}
}
}
internal fun addImageSpan(text : String?, @DrawableRes res_id : Int) {
closeNormalText()
val start = sb.length
sb.append(text)
val end = sb.length
sb.setSpan(EmojiImageSpan(context, res_id), start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
}
internal fun addNetworkEmojiSpan(text : String, url : String) {
closeNormalText()
val start = sb.length
sb.append(text)
val end = sb.length
sb.setSpan(NetworkEmojiSpan(url), start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
}
}
private fun splitShortCode(
s : String, startArg : Int, end : Int, callback : ShortCodeSplitterCallback
) {
var start = startArg
var i = start
while(i < end) {
// 絵文字パターンの開始位置を探索する
start = i
while(i < end) {
val c = s.codePointAt(i)
val width = Character.charCount(c)
if(c == intCharColon) {
if(App1.allow_non_space_before_emoji_shortcode) {
// アプリ設定により、: の手前に空白を要求しない
break
} else if(i + width < end && s.codePointAt(i + width) == intCharAt) {
// フレニコのプロフ絵文字 :@who: は手前の空白を要求しない
break
} else if(i == 0 || CharacterGroup.isWhitespace(s.codePointBefore(i))) {
// ショートコードの手前は始端か改行か空白文字でないとならない
// 空白文字の判定はサーバサイドのそれにあわせる
break
}
// shortcodeの開始とみなせないケースだった
}
i += width
}
if(i > start) {
callback.onString(s.substring(start, i))
}
if(i >= end) break
start = i ++ // start=コロンの位置 i=その次の位置
var emoji_end = - 1
while(i < end) {
val c = s.codePointAt(i)
if(c == intCharColon) {
emoji_end = i
break
}
if(! isShortCodeCharacter(c)) {
break
}
i += Character.charCount(c)
}
// 絵文字がみつからなかったら、startの位置のコロンだけを処理して残りは次のループで処理する
if(emoji_end == - 1 || emoji_end - start < 3) {
callback.onString(":")
i = start + 1
continue
}
callback.onShortCode(
s.substring(start, emoji_end + 1) // ":shortcode:"
, s.substring(start + 1, emoji_end) // "shortcode"
)
i = emoji_end + 1 // コロンの次の位置
}
}
fun decodeEmoji(
context : Context, s : String, options : DecodeOptions
) : Spannable {
val decode_env = DecodeEnv(context, options)
val custom_map = options.emojiMapCustom
val profile_emojis = options.emojiMapProfile
splitShortCode(s, 0, s.length, object : ShortCodeSplitterCallback {
override fun onString(part : String) {
decode_env.addUnicodeString(part)
}
override fun onShortCode(part : String, name : String) {
val info = EmojiMap201709.sShortNameToImageId[name.toLowerCase().replace('-', '_')]
if(info != null) {
decode_env.addImageSpan(part, info.image_id)
return
}
// part=":@name:" name="@name"
if(name.length >= 2 && name[0] == '@') {
if(profile_emojis != null) {
var emoji : NicoProfileEmoji? = profile_emojis[name]
if(emoji == null) emoji = profile_emojis[name.substring(1)]
if(emoji != null) {
val url = emoji.url
if(url.isNotEmpty()) decode_env.addNetworkEmojiSpan(part, url)
}
}
return
}
val emoji = custom_map?.get(name)
if(emoji != null) {
val url = if(App1.disable_emoji_animation && emoji.static_url?.isNotEmpty() == true) emoji.static_url else emoji.url
decode_env.addNetworkEmojiSpan(part, url)
return
}
when {
reHohoemi.matcher(name).find() -> decode_env.addImageSpan(part, R.drawable.emoji_hohoemi)
reNicoru.matcher(name).find() -> decode_env.addImageSpan(part, R.drawable.emoji_nicoru)
else -> decode_env.addUnicodeString(part)
}
}
})
decode_env.closeNormalText()
return decode_env.sb
}
// 投稿などの際、表示は不要だがショートコード=>Unicodeの解決を行いたい場合がある
// カスタム絵文字の変換も行わない
fun decodeShortCode(s : String) : String {
val sb = StringBuilder()
splitShortCode(s, 0, s.length, object : ShortCodeSplitterCallback {
override fun onString(part : String) {
sb.append(part)
}
override fun onShortCode(part : String, name : String) {
val info = EmojiMap201709.sShortNameToImageId[name.toLowerCase().replace('-', '_')]
sb.append(info?.unified ?: part)
}
})
return sb.toString()
}
// 入力補完用。絵文字ショートコード一覧を部分一致で絞り込む
internal fun searchShortCode(context : Context, prefix : String, limit : Int) : ArrayList<CharSequence> {
val dst = ArrayList<CharSequence>()
for(shortCode in EmojiMap201709.sShortNameList) {
if(dst.size >= limit) break
if(! shortCode.contains(prefix)) continue
val info = EmojiMap201709.sShortNameToImageId[shortCode] ?: continue
val sb = SpannableStringBuilder()
sb.append(' ')
val start = 0
val end = sb.length
sb.setSpan(EmojiImageSpan(context, info.image_id), start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
sb.append(' ')
sb.append(':')
sb.append(shortCode)
sb.append(':')
dst.add(sb)
}
return dst
}
}