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

476 lines
13 KiB
Kotlin
Raw Normal View History

package jp.juggler.subwaytooter.util
import android.content.Context
import android.content.SharedPreferences
import android.text.Spannable
import android.text.SpannableStringBuilder
import android.text.Spanned
import android.util.SparseBooleanArray
import androidx.annotation.DrawableRes
import jp.juggler.emoji.EmojiMap
import jp.juggler.subwaytooter.App1
import jp.juggler.subwaytooter.Pref
import jp.juggler.subwaytooter.R
import jp.juggler.subwaytooter.api.entity.CustomEmoji
import jp.juggler.subwaytooter.span.EmojiImageSpan
import jp.juggler.subwaytooter.span.HighlightSpan
import jp.juggler.subwaytooter.span.NetworkEmojiSpan
import jp.juggler.subwaytooter.span.createSpan
import jp.juggler.subwaytooter.table.HighlightWord
2018-12-01 00:02:18 +01:00
import jp.juggler.util.codePointBefore
import java.util.*
import java.util.regex.Pattern
object EmojiDecoder {
private const val cpColon = ':'.toInt()
private const val cpZwsp = '\u200B'.toInt()
fun customEmojiSeparator(pref : SharedPreferences) = if(Pref.bpCustomEmojiSeparatorZwsp(pref)) {
'\u200B'
} else {
' '
}
// タンス側が落ち着いたら [^[:almun:]_] から [:space:]に切り替える
// private fun isHeadOrAfterWhitespace( s:CharSequence,index:Int):Boolean {
// val cp = s.codePointBefore(index)
// return cp == -1 || CharacterGroup.isWhitespace(cp)
// }
fun canStartShortCode(s : CharSequence, index : Int) : Boolean {
return when(val cp = s.codePointBefore(index)) {
- 1 -> true
cpColon -> false
cpZwsp -> true
// rubyの (Letter | Mark | Decimal_Number) はNG
// ftp://unicode.org/Public/5.1.0/ucd/UCD.html#General_Category_Values
else -> when(Character.getType(cp).toByte()) {
// Letter
// LCはエイリアスなので文字から得られることはないはず
Character.UPPERCASE_LETTER,
Character.LOWERCASE_LETTER,
Character.TITLECASE_LETTER,
Character.MODIFIER_LETTER,
Character.OTHER_LETTER -> false
// Mark
Character.NON_SPACING_MARK,
Character.COMBINING_SPACING_MARK,
Character.ENCLOSING_MARK -> false
// Decimal_Number
Character.DECIMAL_DIGIT_NUMBER -> false
else -> true
}
}
// https://mastodon.juggler.jp/@tateisu/99727683089280157
// https://github.com/tootsuite/mastodon/pull/5570 がマージされたらこっちに切り替える
// return cp == -1 || CharacterGroup.isWhitespace(cp)
}
fun canStartHashtag(s : CharSequence, index : Int) : Boolean {
val cp = s.codePointBefore(index)
// HASHTAG_RE = /(?:^|[^\/\)\w])#(#{HASHTAG_NAME_RE})/i
return if(cp >= 0x80) {
true
} else when(cp.toChar()) {
'/' -> false
')' -> false
'_' -> false
in 'a' .. 'z' -> false
in 'A' .. 'Z' -> false
in '0' .. '9' -> false
else -> true
}
}
private class EmojiStringBuilder(internal val options : DecodeOptions) {
internal val sb = SpannableStringBuilder()
internal var normal_char_start = - 1
private fun openNormalText() {
2018-01-19 14:15:39 +01:00
if(normal_char_start == - 1) {
normal_char_start = sb.length
}
}
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) {
2018-01-19 14:15:39 +01:00
val list = options.highlightTrie?.matchList(sb, start, end) ?: return
for(range in list) {
val word = HighlightWord.load(range.word) ?: continue
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) {
if(options.highlightSound == null) options.highlightSound = word
}
if(word.speech != 0) {
if(options.highlightSpeech == null) options.highlightSpeech = word
}
if(options.highlightAny == null) options.highlightAny = word
}
}
2018-01-19 14:15:39 +01:00
internal fun addNetworkEmojiSpan(text : String, url : String) {
closeNormalText()
val start = sb.length
sb.append(text)
val end = sb.length
sb.setSpan(
NetworkEmojiSpan(url, scale = options.enlargeCustomEmoji),
2018-01-19 14:15:39 +01:00
start,
end,
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
)
}
2018-05-01 06:43:27 +02:00
internal fun addImageSpan(text : String, @DrawableRes res_id : Int) {
val context = options.context
if(context == null) {
2018-05-01 06:43:27 +02:00
openNormalText()
sb.append(text)
} else {
closeNormalText()
val start = sb.length
sb.append(text)
val end = sb.length
sb.setSpan(
EmojiImageSpan(context, res_id ,scale=options.enlargeEmoji),
start,
end,
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
)
}
2018-01-19 14:15:39 +01:00
}
internal fun addImageSpan(text : String, er : EmojiMap.EmojiResource) {
val context = options.context
if(context == null) {
openNormalText()
sb.append(text)
} else {
closeNormalText()
val start = sb.length
sb.append(text)
val end = sb.length
sb.setSpan(
er.createSpan(context,scale=options.enlargeEmoji),
start,
end,
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
)
}
}
val evs = EmojiMap.EmojiResource(0)
internal fun addUnicodeString(s : String) {
var i = 0
val end = s.length
while(i < end) {
val remain = end - i
var emoji : String? = null
var emojiResource : EmojiMap.EmojiResource? = null
for(j in EmojiMap.utf16_max_length downTo 1) {
if(j > remain) continue
val check = s.substring(i, i + j)
emojiResource = EmojiMap.sUTF16ToEmojiResource[check] ?: continue
2018-01-19 14:15:39 +01:00
emoji = if(j < remain && s[i + j].toInt() == 0xFE0E) {
// 絵文字バリエーション・シーケンスEVSのU+FE0EVS-15が直後にある場合
// その文字を絵文字化しない
emojiResource = evs
2018-01-19 14:15:39 +01:00
s.substring(i, i + j + 1)
} else {
check
}
2018-01-19 14:15:39 +01:00
break
}
if(emojiResource != null && emoji != null) {
if(emojiResource == evs) {
// 絵文字バリエーション・シーケンスEVSのU+FE0EVS-15が直後にある場合
// その文字を絵文字化しない
2018-01-19 14:15:39 +01:00
openNormalText()
sb.append(emoji)
} else {
addImageSpan(emoji, emojiResource)
}
i += emoji.length
} else {
2018-01-19 14:15:39 +01:00
openNormalText()
val length = Character.charCount(s.codePointAt(i))
if(length == 1) {
val c = s[i ++]
sb.append(
when(c) {
// https://github.com/tateisu/SubwayTooter/issues/69
'\u00AD' -> '-'
else -> c
}
)
2018-01-19 14:15:39 +01:00
} else {
sb.append(s.substring(i, i + length))
i += length
}
}
}
}
2018-01-19 14:15:39 +01:00
}
private const val codepointColon = ':'.toInt()
private const val codepointAtmark = '@'.toInt()
2018-01-19 14:15:39 +01:00
private val shortCodeCharacterSet =
SparseBooleanArray().apply {
for(c in 'A' .. 'Z') put(c.toInt(), true)
for(c in 'a' .. 'z') put(c.toInt(), true)
for(c in '0' .. '9') put(c.toInt(), true)
for(c in "+-_@:") put(c.toInt(), true)
}
private val profileEmojiCharacterSet =
shortCodeCharacterSet.clone().apply {
for(c in ".") put(c.toInt(), true)
}
2018-01-19 14:15:39 +01:00
private interface ShortCodeSplitterCallback {
fun onString(part : String) // shortcode以外の文字列
fun onShortCode(
prevCodePoint : Int,
part : String,
name : String
) // part : ":shortcode:", name : "shortcode"
}
private fun splitShortCode(
2018-01-19 14:15:39 +01:00
s : String,
startArg:Int = 0,
end :Int = s.length,
2018-01-19 14:15:39 +01:00
callback : ShortCodeSplitterCallback
) {
var i = startArg
while(i < end) {
2018-01-19 14:15:39 +01:00
// ":"以外を読み飛ばす
var start = i
loop@ while(i < end) {
val c = s.codePointAt(i)
if(c == codepointColon) break@loop
i += Character.charCount(c)
}
if(i > start) callback.onString(s.substring(start, i))
2018-01-19 14:15:39 +01:00
if(i >= end) break
start = i ++ // start=コロンの位置 i=その次の位置
2018-01-19 14:15:39 +01:00
// 閉じるコロンを探す
var posEndColon = - 1
var countAtmark = 0
while(i < end) {
2018-01-19 14:15:39 +01:00
val cp = s.codePointAt(i)
if(cp == codepointColon) {
posEndColon = i
break
}
// ベスフレの @user@host 絵文字は . を考慮する必要がある
if(cp == codepointAtmark) ++ countAtmark
val allowedCodepointMap = when {
countAtmark >= 2 -> profileEmojiCharacterSet
else -> shortCodeCharacterSet
}
if(! allowedCodepointMap.get(cp, false)) break
2018-01-19 14:15:39 +01:00
i += Character.charCount(cp)
}
2018-01-19 14:15:39 +01:00
// 閉じるコロンが見つからないか、shortcodeが短すぎるなら
// startの位置のコロンだけを処理して残りは次のループで処理する
if(posEndColon == - 1 || posEndColon - start < 2) {
callback.onString(":")
i = start + 1
continue
}
val prevCodePoint = when {
start <= 0 -> 0x20
else -> s.codePointBefore(start)
2019-01-06 09:20:37 +01:00
}
callback.onShortCode(
2019-01-06 09:20:37 +01:00
prevCodePoint,
s.substring(start, posEndColon + 1), // ":shortcode:"
s.substring(start + 1, posEndColon) // "shortcode"
)
2018-01-19 14:15:39 +01:00
i = posEndColon + 1 // コロンの次の位置
}
}
private val reNicoru = Pattern.compile("\\Anicoru\\d*\\z", Pattern.CASE_INSENSITIVE)
private val reHohoemi = Pattern.compile("\\Ahohoemi\\d*\\z", Pattern.CASE_INSENSITIVE)
fun decodeEmoji(options : DecodeOptions, s : String) : Spannable {
val builder = EmojiStringBuilder(options)
val emojiMapCustom = options.emojiMapCustom
val emojiMapProfile = options.emojiMapProfile
val useEmojioneShortcode = when(val context = options.context) {
null -> false
else -> Pref.bpEmojioneShortcode(App1.getAppState(context).pref)
}
splitShortCode(s, callback = object : ShortCodeSplitterCallback {
override fun onString(part : String) {
builder.addUnicodeString(part)
}
override fun onShortCode(prevCodePoint : Int, part : String, name : String) {
// フレニコのプロフ絵文字
if(emojiMapProfile != null && name.length >= 2 && name[0] == '@') {
val emojiProfile = emojiMapProfile[name] ?: emojiMapProfile[name.substring(1)]
if(emojiProfile != null) {
val url = emojiProfile.url
if(url.isNotEmpty()) {
builder.addNetworkEmojiSpan(part, url)
return
}
}
}
// カスタム絵文字
val emojiCustom = emojiMapCustom?.get(name)
if(emojiCustom != null) {
val url = when {
Pref.bpDisableEmojiAnimation(App1.pref) && emojiCustom.static_url?.isNotEmpty() == true -> emojiCustom.static_url
else -> emojiCustom.url
}
builder.addNetworkEmojiSpan(part, url)
return
}
// 通常の絵文字
if( useEmojioneShortcode ){
val info =
EmojiMap.sShortNameToEmojiInfo[name.toLowerCase(Locale.JAPAN).replace('-', '_')]
if(info != null) {
builder.addImageSpan(part, info.er)
return
}
}
when {
reHohoemi.matcher(name).find() -> builder.addImageSpan(
part,
R.drawable.emoji_hohoemi
)
reNicoru.matcher(name).find() -> builder.addImageSpan(
part,
R.drawable.emoji_nicoru
)
else -> builder.addUnicodeString(part)
}
}
})
builder.closeNormalText()
return builder.sb
}
// 投稿などの際、表示は不要だがショートコード=>Unicodeの解決を行いたい場合がある
// カスタム絵文字の変換も行わない
fun decodeShortCode(
s : String,
emojiMapCustom : HashMap<String, CustomEmoji>? = null
) : String {
val sb = StringBuilder()
splitShortCode(s, callback = object : ShortCodeSplitterCallback {
override fun onString(part : String) {
sb.append(part)
}
override fun onShortCode(prevCodePoint : Int, part : String, name : String) {
// カスタム絵文字にマッチするなら変換しない
val emojiCustom = emojiMapCustom?.get(name)
if(emojiCustom != null) {
sb.append(part)
return
}
// カスタム絵文字ではなく通常の絵文字のショートコードなら絵文字に変換する
val info =
EmojiMap.sShortNameToEmojiInfo[name.toLowerCase(Locale.JAPAN).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 EmojiMap.sShortNameList) {
if(dst.size >= limit) break
if(! shortCode.contains(prefix)) continue
val info = EmojiMap.sShortNameToEmojiInfo[shortCode] ?: continue
val sb = SpannableStringBuilder()
val start = 0
2018-01-19 14:15:39 +01:00
sb.append(' ')
val end = sb.length
sb.setSpan(
info.er.createSpan(context),
start,
end,
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
)
2018-01-19 14:15:39 +01:00
sb.append(' ')
2018-01-19 14:15:39 +01:00
.append(':')
.append(shortCode)
.append(':')
dst.add(sb)
}
return dst
}
}