- IDEが解釈を諦めるくらい長くなったMisskeyMarkdownDecoderを複数ファイルに分割。

- CustomEmojiCacheの無駄な警告ログを抑制。
- 「アプリ設定/見た目/(Misskey)テキスト装飾を表示する」を追加。デフォルト有効。
- 「アプリ設定/見た目/(Misskey)未対応のマークアップを表示する」を追加。デフォルト有効。
This commit is contained in:
tateisu 2021-11-06 09:36:00 +09:00
parent 9b34fc53a4
commit 793ed3a5aa
21 changed files with 2304 additions and 1986 deletions

View File

@ -458,10 +458,20 @@ object PrefB {
"MultiWindowPost",
false
)
val bpManyWindowPost = BooleanPref(
"ManyWindowPost",
false
)
val bpMfmDecorationEnabled = BooleanPref(
"MfmDecorationEnabled",
true
)
val bpMfmDecorationShowUnsupportedMarkup = BooleanPref(
"MfmDecorationShowUnsupportedMarkup",
true
)
}
object PrefI {

View File

@ -10,6 +10,7 @@ import jp.juggler.subwaytooter.PrefB
import jp.juggler.subwaytooter.R
import jp.juggler.subwaytooter.api.*
import jp.juggler.subwaytooter.emoji.CustomEmoji
import jp.juggler.subwaytooter.mfm.SpannableStringBuilderEx
import jp.juggler.subwaytooter.table.HighlightWord
import jp.juggler.subwaytooter.table.SavedAccount
import jp.juggler.subwaytooter.util.*
@ -319,7 +320,7 @@ class TootStatus(parser: TootParser, src: JsonObject) : TimelineItem() {
// Markdownのデコード結果からmentionsを読むのだった
val mentions1 =
(decoded_content as? MisskeyMarkdownDecoder.SpannableStringBuilderEx)?.mentions
(decoded_content as? SpannableStringBuilderEx)?.mentions
val sv = src.string("cw")?.cleanCW()
this.spoiler_text = when {
@ -351,7 +352,7 @@ class TootStatus(parser: TootParser, src: JsonObject) : TimelineItem() {
if (this.highlightAny == null) this.highlightAny = options.highlightAny
val mentions2 =
(decoded_spoiler_text as? MisskeyMarkdownDecoder.SpannableStringBuilderEx)?.mentions
(decoded_spoiler_text as? SpannableStringBuilderEx)?.mentions
this.mentions = mergeMentions(mentions1, mentions2)
this.decoded_mentions =

View File

@ -2,7 +2,7 @@ package jp.juggler.subwaytooter.api.entity
import android.net.Uri
import jp.juggler.subwaytooter.api.TootParser
import jp.juggler.subwaytooter.util.MisskeyMarkdownDecoder
import jp.juggler.subwaytooter.mfm.MisskeyMarkdownDecoder
import jp.juggler.util.*
import java.util.regex.Pattern

View File

@ -745,6 +745,10 @@ val appSettingRoot = AppSettingItem(null, SettingType.Section, R.string.app_sett
sw(PrefB.bpLinksInContextMenu, R.string.show_links_in_context_menu)
sw(PrefB.bpShowLinkUnderline, R.string.show_link_underline)
sw(PrefB.bpMfmDecorationEnabled, R.string.mfm_decoration_enabled)
sw(PrefB.bpMfmDecorationShowUnsupportedMarkup, R.string.mfm_show_unsupported_markup)
sw(
PrefB.bpMoveNotificationsQuickFilter,
R.string.move_notifications_quick_filter_to_column_setting

View File

@ -0,0 +1,54 @@
package jp.juggler.subwaytooter.mfm
import java.util.HashMap
import java.util.regex.Matcher
import java.util.regex.Pattern
// 正規表現パターンごとにMatcherをキャッシュする
// 対象テキストが変わったらキャッシュを捨てて更新する
// Matcher#region(start,text.length) を設定してから返す
// (同一テキストに対してMatcher.usePatternで正規表現パターンを切り替えるのも検討したが、usePatternの方が多分遅くなる)
internal object MatcherCache {
private class MatcherCacheItem(
var matcher: Matcher,
var text: String,
var textHashCode: Int,
)
// スレッドごとにキャッシュ用のマップを持つ
private val matcherCacheMap =
object : ThreadLocal<HashMap<Pattern, MatcherCacheItem>>() {
override fun initialValue(): HashMap<Pattern, MatcherCacheItem> = HashMap()
}
internal fun matcher(
pattern: Pattern,
text: String,
start: Int = 0,
end: Int = text.length,
): Matcher {
val m: Matcher
val textHashCode = text.hashCode()
val map = matcherCacheMap.get()!!
val item = map[pattern]
if (item != null) {
if (item.textHashCode != textHashCode || item.text != text) {
item.matcher = pattern.matcher(text).apply {
useAnchoringBounds(true)
}
item.text = text
item.textHashCode = textHashCode
}
m = item.matcher
} else {
m = pattern.matcher(text).apply {
useAnchoringBounds(true)
}
map[pattern] = MatcherCacheItem(m, text, textHashCode)
}
m.region(start, end)
return m
}
}

View File

@ -0,0 +1,89 @@
package jp.juggler.subwaytooter.mfm
import android.graphics.Color
import jp.juggler.subwaytooter.util.DecodeOptions
import jp.juggler.util.LogCategory
import jp.juggler.util.removeEndWhitespaces
import java.util.ArrayList
object MisskeyMarkdownDecoder {
internal val log = LogCategory("MisskeyMarkdownDecoder")
internal const val DEBUG = false
////////////////////////////////////////////////////////////////////////////
private fun mixColor(
@Suppress("SameParameterValue") col1: Int,
col2: Int,
): Int = Color.rgb(
(Color.red(col1) + Color.red(col2)) ushr 1,
(Color.green(col1) + Color.green(col2)) ushr 1,
(Color.blue(col1) + Color.blue(col2)) ushr 1
)
val quoteNestColors = intArrayOf(
mixColor(Color.GRAY, 0x0000ff),
mixColor(Color.GRAY, 0x0080ff),
mixColor(Color.GRAY, 0x00ff80),
mixColor(Color.GRAY, 0x00ff00),
mixColor(Color.GRAY, 0x80ff00),
mixColor(Color.GRAY, 0xff8000),
mixColor(Color.GRAY, 0xff0000),
mixColor(Color.GRAY, 0xff0080),
mixColor(Color.GRAY, 0x8000ff)
)
// 入力テキストからタグを抽出するために使う
// #を含まないタグ文字列のリスト、またはnullを返す
fun findHashtags(src: String?): ArrayList<String>? {
try {
if (src != null) {
val root = Node(NodeType.ROOT, emptyArray(), null)
NodeParseEnv(useFunction = true, root, src, 0, src.length).parseInside()
val result = ArrayList<String>()
fun track(n: Node) {
if (n.type == NodeType.HASHTAG) result.add(n.args[0])
n.childNodes.forEach { track(it) }
}
track(root)
if (result.isNotEmpty()) return result
}
} catch (ex: Throwable) {
log.e(ex, "findHashtags failed.")
}
return null
}
// このファイルのエントリーポイント
fun decodeMarkdown(options: DecodeOptions, src: String?) =
SpannableStringBuilderEx().apply {
val save = options.enlargeCustomEmoji
options.enlargeCustomEmoji = 2.5f
try {
val env = SpanOutputEnv(options, this)
if (src != null) {
val root = Node(NodeType.ROOT, emptyArray(), null)
NodeParseEnv(
useFunction = (options.linkHelper?.misskeyVersion
?: 12) >= 11, root, src, 0, src.length
).parseInside()
env.fireRender(root).setSpan(env.sb)
}
// 末尾の空白を取り除く
this.removeEndWhitespaces()
} catch (ex: Throwable) {
log.trace(ex)
log.e(ex, "decodeMarkdown failed")
} finally {
options.enlargeCustomEmoji = save
}
}
}

View File

@ -0,0 +1,861 @@
package jp.juggler.subwaytooter.mfm
import android.util.SparseArray
import android.util.SparseBooleanArray
import jp.juggler.subwaytooter.api.entity.TootAccount
import jp.juggler.util.LogCategory
import jp.juggler.util.asciiPattern
import jp.juggler.util.ellipsizeDot3
import jp.juggler.util.groupEx
import java.util.*
import java.util.regex.Pattern
object MisskeyMarkdownDecoderExt {
private val log = LogCategory("MisskeyMarkdownDecoderExt")
// ブロック要素は始端と終端の空行を除去したい
val reStartEmptyLines = """\A(?:[  ]*?[\x0d\x0a]+)+""".toRegex()
val reEndEmptyLines = """[\s\x0d\x0a]+\z""".toRegex()
fun trimBlock(s: String) =
s.replace(reStartEmptyLines, "")
.replace(reEndEmptyLines, "")
// あるノードが内部に持てるノード種別のマップ
val mapAllowInside = HashMap<NodeType, HashSet<NodeType>>().apply {
fun <T> hashSetOf(vararg values: T) = HashSet<T>().apply { addAll(values) }
infix fun NodeType.wraps(inner: HashSet<NodeType>) = put(this, inner)
// EMOJI, HASHTAG, MENTION, CODE_BLOCK, QUOTE_INLINE, SEARCH 等はマークダウン要素のネストを許可しない
NodeType.BIG wraps
hashSetOf(
NodeType.EMOJI,
NodeType.HASHTAG,
NodeType.MENTION,
NodeType.FUNCTION,
NodeType.LATEX,
NodeType.STRIKE,
NodeType.SMALL,
NodeType.ITALIC
)
NodeType.BOLD wraps
hashSetOf(
NodeType.EMOJI,
NodeType.HASHTAG,
NodeType.MENTION,
NodeType.FUNCTION,
NodeType.LATEX,
NodeType.URL,
NodeType.LINK,
NodeType.STRIKE,
NodeType.SMALL,
NodeType.ITALIC
)
NodeType.STRIKE wraps
hashSetOf(
NodeType.EMOJI,
NodeType.HASHTAG,
NodeType.MENTION,
NodeType.FUNCTION,
NodeType.LATEX,
NodeType.URL,
NodeType.LINK,
NodeType.BIG,
NodeType.BOLD,
NodeType.SMALL,
NodeType.ITALIC
)
NodeType.SMALL wraps
hashSetOf(
NodeType.EMOJI,
NodeType.HASHTAG,
NodeType.MENTION,
NodeType.FUNCTION,
NodeType.LATEX,
NodeType.URL,
NodeType.LINK,
NodeType.BOLD,
NodeType.STRIKE,
NodeType.ITALIC
)
NodeType.ITALIC wraps
hashSetOf(
NodeType.EMOJI,
NodeType.HASHTAG,
NodeType.MENTION,
NodeType.FUNCTION,
NodeType.LATEX,
NodeType.URL,
NodeType.LINK,
NodeType.BIG,
NodeType.BOLD,
NodeType.STRIKE,
NodeType.SMALL
)
NodeType.MOTION wraps
hashSetOf(
NodeType.EMOJI,
NodeType.HASHTAG,
NodeType.MENTION,
NodeType.FUNCTION,
NodeType.LATEX,
NodeType.URL,
NodeType.LINK,
NodeType.BOLD,
NodeType.STRIKE,
NodeType.SMALL,
NodeType.ITALIC
)
NodeType.LINK wraps
hashSetOf(
NodeType.EMOJI,
NodeType.MOTION,
NodeType.FUNCTION,
NodeType.LATEX,
NodeType.BIG,
NodeType.BOLD,
NodeType.STRIKE,
NodeType.SMALL,
NodeType.ITALIC
)
NodeType.TITLE wraps
hashSetOf(
NodeType.EMOJI,
NodeType.HASHTAG,
NodeType.MENTION,
NodeType.FUNCTION,
NodeType.LATEX,
NodeType.URL,
NodeType.LINK,
NodeType.BIG,
NodeType.BOLD,
NodeType.STRIKE,
NodeType.SMALL,
NodeType.ITALIC,
NodeType.MOTION,
NodeType.CODE_INLINE
)
NodeType.CENTER wraps
hashSetOf(
NodeType.EMOJI,
NodeType.HASHTAG,
NodeType.MENTION,
NodeType.FUNCTION,
NodeType.LATEX,
NodeType.URL,
NodeType.LINK,
NodeType.BIG,
NodeType.BOLD,
NodeType.STRIKE,
NodeType.SMALL,
NodeType.ITALIC,
NodeType.MOTION,
NodeType.CODE_INLINE
)
NodeType.FUNCTION wraps hashSetOf(
NodeType.CODE_BLOCK,
NodeType.QUOTE_INLINE,
NodeType.SEARCH,
NodeType.EMOJI,
NodeType.HASHTAG,
NodeType.MENTION,
NodeType.LATEX,
NodeType.URL,
NodeType.LINK,
NodeType.BIG,
NodeType.BOLD,
NodeType.STRIKE,
NodeType.SMALL,
NodeType.ITALIC,
NodeType.MOTION,
NodeType.CODE_INLINE,
NodeType.TITLE,
NodeType.CENTER,
NodeType.QUOTE_BLOCK
)
NodeType.LATEX wraps hashSetOf(
NodeType.CODE_BLOCK,
NodeType.QUOTE_INLINE,
NodeType.SEARCH,
NodeType.EMOJI,
NodeType.HASHTAG,
NodeType.MENTION,
NodeType.FUNCTION,
NodeType.URL,
NodeType.LINK,
NodeType.BIG,
NodeType.BOLD,
NodeType.STRIKE,
NodeType.SMALL,
NodeType.ITALIC,
NodeType.MOTION,
NodeType.CODE_INLINE,
NodeType.TITLE,
NodeType.CENTER,
NodeType.QUOTE_BLOCK
)
// all except ROOT,TEXT
val allSet = hashSetOf(
NodeType.CODE_BLOCK,
NodeType.QUOTE_INLINE,
NodeType.SEARCH,
NodeType.EMOJI,
NodeType.HASHTAG,
NodeType.MENTION,
NodeType.FUNCTION,
NodeType.LATEX,
NodeType.URL,
NodeType.LINK,
NodeType.BIG,
NodeType.BOLD,
NodeType.STRIKE,
NodeType.SMALL,
NodeType.ITALIC,
NodeType.MOTION,
NodeType.CODE_INLINE,
NodeType.TITLE,
NodeType.CENTER,
NodeType.QUOTE_BLOCK
)
NodeType.QUOTE_BLOCK wraps allSet
NodeType.ROOT wraps allSet
}
// ノードのパースを行う関数をキャプチャパラメータつきで生成する
fun simpleParser(
pattern: Pattern,
type: NodeType,
): NodeParseEnv.() -> NodeDetected? = {
val matcher = remainMatcher(pattern)
when {
!matcher.find() -> null
else -> {
val textInside = matcher.groupEx(1)!!
makeDetected(
type,
arrayOf(textInside),
matcher.start(), matcher.end(),
this.text, matcher.start(1), textInside.length
)
}
}
}
private val brackets = arrayOf(
"()",
"()",
"[]",
"{}",
"“”",
"",
"",
"«»",
"",
"",
"",
"⦅⦆",
"⦅⦆",
"〚〛",
"⦃⦄",
"「」",
"〈〉",
"《》",
"【】",
"",
"⦗⦘",
"『』",
"〖〗",
"〘〙",
"[]",
"「」",
"⟦⟧",
"⟨⟩",
"⟪⟫",
"⟮⟯",
"⟬⟭",
"⌈⌉",
"⌊⌋",
"⦇⦈",
"⦉⦊",
"❛❜",
"❝❞",
"",
"❪❫",
"",
"❬❭",
"",
"❰❱",
"",
"()",
"﴿",
"〈〉",
"⦑⦒",
"⧼⧽",
"﹙﹚",
"﹛﹜",
"﹝﹞",
"⁽⁾",
"₍₎",
"⦋⦌",
"⦍⦎",
"⦏⦐",
"⁅⁆",
"⸢⸣",
"⸤⸥",
"⟅⟆",
"⦓⦔",
"⦕⦖",
"⸦⸧",
"⸨⸩",
"⧘⧙",
"⧚⧛",
"⸜⸝",
"⸌⸍",
"⸂⸃",
"⸄⸅",
"⸉⸊",
"᚛᚜",
"༺༻",
"༼༽",
"⏜⏝",
"⎴⎵",
"⏞⏟",
"⏠⏡",
"﹁﹂",
"﹃﹄",
"︹︺",
"︻︼",
"︗︘",
"︿﹀",
"︽︾",
"﹇﹈",
"︷︸"
)
val bracketsMap = HashMap<Char, Int>().apply {
brackets.forEach {
put(it[0], 1)
put(it[1], -1)
}
}
val bracketsMapUrlSafe = HashMap<Char, Int>().apply {
brackets.forEach {
if ("([".contains(it[0])) return@forEach
put(it[0], 1)
put(it[1], -1)
}
}
// 末尾の余計な」や(を取り除く。
// 例えば「#タグ」 とか #タグ)
fun String.removeOrphanedBrackets(urlSafe: Boolean = false): String {
var last = 0
val nests = when (urlSafe) {
true -> this.map {
last += bracketsMapUrlSafe[it] ?: 0
last
}
else -> this.map {
last += bracketsMap[it] ?: 0
last
}
}
// first position of unmatched close
var pos = nests.indexOfFirst { it < 0 }
if (pos != -1) return substring(0, pos)
// last position of unmatched open
pos = nests.indexOfLast { it == 0 }
return substring(0, pos + 1)
}
// [title] 【title】
// 直後に改行が必要だったが文末でも良いことになった https://github.com/syuilo/misskey/commit/79ffbf95db9d0cc019d06ab93b1bfa6ba0d4f9ae
// val titleParser = simpleParser(
// """\A[【\[](.+?)[】\]](\n|\z)""".asciiPattern()
// , NodeType.TITLE
// )
private val reTitle = """\A[【\[](.+?)[】\]](\n|\z)""".asciiPattern()
private val reFunction = """\A\[([^\s\n\[\]]+) \s*([^\n\[\]]+)\]""".asciiPattern()
private fun NodeParseEnv.titleParserImpl(): NodeDetected? {
if (useFunction) {
val type = NodeType.FUNCTION
val matcher = remainMatcher(reFunction)
if (matcher.find()) {
val name = matcher.groupEx(1)?.ellipsizeDot3(3) ?: "???"
val textInside = matcher.groupEx(2)!!
return makeDetected(
type,
arrayOf(name),
matcher.start(), matcher.end(),
this.text, matcher.start(2), textInside.length
)
}
}
val type = NodeType.TITLE
val matcher = remainMatcher(reTitle)
if (matcher.find()) {
val textInside = matcher.groupEx(1)!!
return makeDetected(
type,
arrayOf(textInside),
matcher.start(), matcher.end(),
this.text, matcher.start(1), textInside.length
)
}
return null
}
@Suppress("SpellCheckingInspection")
private val latexEscape = listOf(
"\\#" to "#",
"\\$" to "$",
"\\%" to "%",
"\\&" to "&",
"\\_" to "_",
"\\{" to "{",
"\\}" to "}",
"\\;" to "",
"\\!" to "",
"\\textbackslash" to "\\",
"\\backslash" to "\\",
"\\textasciitilde" to "~",
"\\textasciicircum" to "^",
"\\textbar" to "|",
"\\textless" to "<",
"\\textgreater" to ">",
).sortedByDescending { it.first.length }
private fun partialEquals(src: String, start: Int, needle: String): Boolean {
for (i in needle.indices) {
if (src[start + i] != needle[i]) return false
}
return true
}
private fun String.unescapeLatex(): String {
val sb = StringBuilder(length)
val end = length
var i = 0
while (i < end) {
val c = this[i]
if (c == '\\') {
val pair = latexEscape.find { partialEquals(this, i, it.first) }
if (pair != null) {
sb.append(pair.second)
i += pair.first.length
continue
}
}
sb.append(c)
++i
}
return sb.toString()
}
// \} \]はムダなエスケープに見えるが、androidでは必要なので削ってはいけない
@Suppress("RegExpRedundantEscape")
private val reLatexRemove =
"""\\(?:quad|Huge|atop|sf|scriptsize|bf|small|tiny|underline|large|(?:color)\{[^}]*\})""".toRegex()
@Suppress("RegExpRedundantEscape")
private val reLatex1 =
"""\\(?:(?:url)|(?:textcolor|colorbox)\{[^}]*\}|(?:fcolorbox|raisebox)\{[^}]*\}\{[^}]*\}|includegraphics\[[^]]*\])\{([^}]*)\}""".toRegex()
@Suppress("RegExpRedundantEscape")
private val reLatex2reversed = """\\(?:overset|href)\{([^}]+)\}\{([^}]+)\}""".toRegex()
private fun String.removeLatex(): String {
return this
.replace(reLatexRemove, "")
.replace(reLatex1, "$1")
.replace(reLatex2reversed, "$2 $1")
.unescapeLatex()
}
private val reLatexBlock =
"""^\\\[(.+?)\\\]""".asciiPattern(Pattern.MULTILINE or Pattern.DOTALL)
private val reLatexInline = """\A\\\((.+?)\\\)""".asciiPattern()
private fun NodeParseEnv.latexParserImpl(): NodeDetected? {
val type = NodeType.LATEX
var matcher = remainMatcher(reLatexBlock)
if (matcher.find()) {
val textInside = matcher.groupEx(1)!!.removeLatex().trim()
return makeDetected(
type,
arrayOf(textInside),
matcher.start(), matcher.end(),
textInside, 0, textInside.length
)
}
matcher = remainMatcher(reLatexInline)
if (matcher.find()) {
val textInside = matcher.groupEx(1)!!.removeLatex()
return makeDetected(
type,
arrayOf(textInside),
matcher.start(), matcher.end(),
textInside, 0, textInside.length
)
}
return null
}
// (マークダウン要素の特徴的な文字)と(パーサ関数の配列)のマップ
val nodeParserMap = SparseArray<Array<out NodeParseEnv.() -> NodeDetected?>>().apply {
fun addParser(
firstChars: String,
vararg nodeParsers: NodeParseEnv.() -> NodeDetected?,
) {
for (s in firstChars) {
put(s.code, nodeParsers)
}
}
// Strike ~~...~~
addParser(
"~", simpleParser(
"""\A~~(.+?)~~""".asciiPattern(), NodeType.STRIKE
)
)
// Quote "..."
addParser(
"\"", simpleParser(
"""\A"([^\x0d\x0a]+?)\n"[\x0d\x0a]*""".asciiPattern(), NodeType.QUOTE_INLINE
)
)
// Quote (行頭)>...(改行)
// この正規表現の場合は \A ではなく ^ で各行の始端にマッチさせる
val reQuoteBlock = """^>(?:[  ]?)([^\x0d\x0a]*)(\x0a|\x0d\x0a?)?"""
.asciiPattern(Pattern.MULTILINE)
addParser(">", {
if (pos > 0) {
val c = text[pos - 1]
if (c != '\r' && c != '\n') {
//直前が改行文字ではない
if (MisskeyMarkdownDecoder.DEBUG) log.d("QUOTE: previous char is not line end. $c pos=$pos text=$text")
return@addParser null
}
}
var p = pos
val content = StringBuilder()
val matcher = remainMatcher(reQuoteBlock)
while (true) {
if (!matcher.find(p)) break
p = matcher.end()
if (content.isNotEmpty()) content.append('\n')
content.append(matcher.groupEx(1))
// 改行の直後なので次回マッチの ^ は大丈夫なはず…
}
if (content.isNotEmpty()) content.append('\n')
if (p <= pos) {
// > のあとに全く何もない
if (MisskeyMarkdownDecoder.DEBUG) log.d("QUOTE: not a quote")
return@addParser null
}
val textInside = content.toString()
makeDetected(
NodeType.QUOTE_BLOCK,
emptyArray(),
pos, p,
textInside, 0, textInside.length
)
})
// 絵文字 :emoji:
addParser(
":",
simpleParser(
"""\A:([a-zA-Z0-9+-_@]+):""".asciiPattern(), NodeType.EMOJI
)
)
// モーション
addParser(
"(", simpleParser(
"""\A\Q(((\E(.+?)\Q)))\E""".asciiPattern(Pattern.DOTALL), NodeType.MOTION
)
)
val reHtmlTag = """\A<([a-z]+)>(.+?)</\1>""".asciiPattern(Pattern.DOTALL)
addParser("<", {
val matcher = remainMatcher(reHtmlTag)
when {
!matcher.find() -> null
else -> {
val tagName = matcher.groupEx(1)!!
val textInside = matcher.groupEx(2)!!
fun a(type: NodeType) = makeDetected(
type,
arrayOf(textInside),
matcher.start(), matcher.end(),
this.text, matcher.start(2), textInside.length
)
when (tagName) {
"motion" -> a(NodeType.MOTION)
"center" -> a(NodeType.CENTER)
"small" -> a(NodeType.SMALL)
"i" -> a(NodeType.ITALIC)
else -> null
}
}
}
})
// ***big*** **bold**
addParser(
"*",
// 処理順序に意味があるので入れ替えないこと
// 記号列が長い順にパースを試す
simpleParser(
"""^\Q***\E(.+?)\Q***\E""".asciiPattern(),
NodeType.BIG
),
simpleParser(
"""^\Q**\E(.+?)\Q**\E""".asciiPattern(),
NodeType.BOLD
),
)
val reAlnum = """[A-Za-z0-9]""".asciiPattern()
// http(s)://....
val reUrl = """\A(https?://[\w/:%#@${'$'}&?!()\[\]~.=+\-]+)"""
.asciiPattern()
addParser("h", {
// 直前の文字が英数字ならURLの開始とはみなさない
if (pos > 0 && MatcherCache.matcher(reAlnum, text, pos - 1, pos).find()) {
return@addParser null
}
val matcher = remainMatcher(reUrl)
if (!matcher.find()) {
return@addParser null
}
val url = matcher.groupEx(1)!!.removeOrphanedBrackets(urlSafe = true)
makeDetected(
NodeType.URL,
arrayOf(url),
matcher.start(), matcher.start() + url.length,
"", 0, 0
)
})
// 検索
val reSearchButton = """\A(検索|\[検索]|Search|\[Search])(\n|\z)"""
.asciiPattern(Pattern.CASE_INSENSITIVE)
fun NodeParseEnv.parseSearchPrev(): String? {
val prev = text.substring(lastEnd, pos)
val delm = prev.lastIndexOf('\n')
val end = prev.length
return when {
end <= 1 -> null // キーワードを含まないくらい短い
delm + 1 >= end - 1 -> null // 改行より後の部分が短すぎる
!"  ".contains(prev.last()) -> null // 末尾が空白ではない
else -> prev.substring(delm + 1, end - 1) // キーワード部分を返す
}
}
val searchParser: NodeParseEnv.() -> NodeDetected? = {
val matcher = remainMatcher(reSearchButton)
when {
!matcher.find() -> null
else -> {
val keyword = parseSearchPrev()
when {
keyword?.isEmpty() != false -> null
else -> makeDetected(
NodeType.SEARCH,
arrayOf(keyword),
pos - (keyword.length + 1), matcher.end(),
this.text, pos - (keyword.length + 1), keyword.length
)
}
}
}
}
val titleParser: NodeParseEnv.() -> NodeDetected? = { titleParserImpl() }
// Link
val reLink = """\A\??\[([^\n\[\]]+?)]\((https?://[\w/:%#@${'$'}&?!()\[\]~.=+\-]+?)\)"""
.asciiPattern()
val linkParser: NodeParseEnv.() -> NodeDetected? = {
val matcher = remainMatcher(reLink)
when {
!matcher.find() -> null
else -> {
val title = matcher.groupEx(1)!!
makeDetected(
NodeType.LINK,
arrayOf(
title,
matcher.groupEx(2)!!, // url
text[pos].toString() // silent なら "?" になる
),
matcher.start(), matcher.end(),
this.text, matcher.start(1), title.length
)
}
}
}
// [ はいろんな要素で使われる
// searchの判定をtitleより前に行うこと。 「abc [検索] 」でtitleが優先されるとマズい
// v10でもv12でもlinkの優先度はtitleやfunctionより高い
addParser("[", searchParser, linkParser, titleParser)
// その他の文字でも判定する
addParser("", titleParser)
addParser("検Ss", searchParser)
addParser("?", linkParser)
// \(…\) \{…\}
addParser("\\", { latexParserImpl() })
// メールアドレスの@の手前に使える文字なら真
val mailChars = SparseBooleanArray().apply {
for (it in '0'..'9') {
put(it.code, true)
}
for (it in 'A'..'Z') {
put(it.code, true)
}
for (it in 'a'..'z') {
put(it.code, true)
}
"""${'$'}!#%&'`"*+-/=?^_{|}~""".forEach { put(it.code, true) }
}
addParser("@", {
val matcher = remainMatcher(TootAccount.reMisskeyMentionMFM)
when {
!matcher.find() -> null
else -> when {
// 直前の文字がメールアドレスの@の手前に使える文字ならメンションではない
pos > 0 && mailChars.get(text.codePointBefore(pos)) -> null
else -> {
// log.d(
// "mention detected: ${matcher.group(1)},${matcher.group(2)},${
// matcher.group(
// 0
// )
// }"
// )
makeDetected(
NodeType.MENTION,
arrayOf(
matcher.groupEx(1)!!,
matcher.groupEx(2) ?: "" // username, host
),
matcher.start(), matcher.end(),
"", 0, 0
)
}
}
}
})
// Hashtag
val reHashtag = """\A#([^\s.,!?#:]+)""".asciiPattern()
val reDigitsOnly = """\A\d*\z""".asciiPattern()
addParser("#", {
if (pos > 0 && MatcherCache.matcher(reAlnum, text, pos - 1, pos).find()) {
// 直前に英数字があるならタグにしない
return@addParser null
}
val matcher = remainMatcher(reHashtag)
if (!matcher.find()) {
// タグにマッチしない
return@addParser null
}
// 先頭の#を含まないタグテキスト
val tag = matcher.groupEx(1)!!.removeOrphanedBrackets()
if (tag.isEmpty() || tag.length > 50 || reDigitsOnly.matcher(tag).find()) {
// 空文字列、50文字超過、数字だけのタグは不許可
return@addParser null
}
makeDetected(
NodeType.HASHTAG,
arrayOf(tag),
matcher.start(), matcher.start() + 1 + tag.length,
"", 0, 0
)
})
// code (ブロック、インライン)
addParser(
"`", simpleParser(
"""\A```(?:.*)\n([\s\S]+?)\n```(?:\n|$)""".asciiPattern(), NodeType.CODE_BLOCK
/*
(A)
```code``` 閉じる部分の前後に改行がないのでダメ
(B)
```lang
code
code
code
```
はlang部分は表示されない
(C)
STの表示上の都合で閉じる部分の後の改行が複数あっても全て除去する
*/
), simpleParser(
// インラインコードは内部にとある文字を含むと認識されない。理由は顔文字と衝突するからだとか
"""\A`([^`´\x0d\x0a]+)`""".asciiPattern(), NodeType.CODE_INLINE
)
)
}
}

View File

@ -0,0 +1,402 @@
package jp.juggler.subwaytooter.mfm
import android.graphics.Color
import android.graphics.Typeface
import android.text.style.ForegroundColorSpan
import android.util.SparseBooleanArray
import jp.juggler.util.asciiPattern
import jp.juggler.util.firstNonNull
import jp.juggler.util.fontSpan
import jp.juggler.util.groupEx
import java.util.*
import java.util.regex.Matcher
import java.util.regex.Pattern
// ```code``` マークダウン内部ではプログラムっぽい何かの文法強調表示が行われる
object MisskeySyntaxHighlighter {
private val keywords = HashSet<String>().apply {
val _keywords = arrayOf(
"true",
"false",
"null",
"nil",
"undefined",
"void",
"var",
"const",
"let",
"mut",
"dim",
"if",
"then",
"else",
"switch",
"match",
"case",
"default",
"for",
"each",
"in",
"while",
"loop",
"continue",
"break",
"do",
"goto",
"next",
"end",
"sub",
"throw",
"try",
"catch",
"finally",
"enum",
"delegate",
"function",
"func",
"fun",
"fn",
"return",
"yield",
"async",
"await",
"require",
"include",
"import",
"imports",
"export",
"exports",
"from",
"as",
"using",
"use",
"internal",
"module",
"namespace",
"where",
"select",
"struct",
"union",
"new",
"delete",
"this",
"super",
"base",
"class",
"interface",
"abstract",
"static",
"public",
"private",
"protected",
"virtual",
"partial",
"override",
"extends",
"implements",
"constructor"
)
// lower
addAll(_keywords)
// UPPER
addAll(_keywords.map { it.uppercase() })
// Snake
addAll(_keywords.map { k -> k[0].uppercase() + k.substring(1) })
add("NaN")
// 識別子に対して既存の名前と一致するか調べるようになったので、もはやソートの必要はない
}
private val symbolMap = SparseBooleanArray().apply {
"=+-*/%~^&|><!?".forEach { put(it.code, true) }
}
// 文字列リテラルの開始文字のマップ
private val stringStart = SparseBooleanArray().apply {
"\"'`".forEach { put(it.code, true) }
}
private class Token(
val length: Int,
val color: Int = 0,
val italic: Boolean = false,
val comment: Boolean = false,
)
private class Env(
val source: String,
val start: Int,
val end: Int,
) {
// 出力先2
val spanList = SpanList()
fun push(start: Int, token: Token) {
val end = start + token.length
if (token.comment) {
spanList.addLast(start, end, ForegroundColorSpan(Color.BLACK or 0x808000))
} else {
var c = token.color
if (c != 0) {
if (c < 0x1000000) {
c = c or Color.BLACK
}
spanList.addLast(start, end, ForegroundColorSpan(c))
}
if (token.italic) {
spanList.addLast(
start,
end,
fontSpan(Typeface.defaultFromStyle(Typeface.ITALIC))
)
}
}
}
// スキャン位置
var pos: Int = start
fun remainMatcher(pattern: Pattern): Matcher =
MatcherCache.matcher(pattern, source, pos, end)
fun parse(): SpanList {
var i = start
var lastEnd = start
fun closeTextToken(textEnd: Int) {
val length = textEnd - lastEnd
if (length > 0) {
push(lastEnd, Token(length = length))
lastEnd = textEnd
}
}
while (i < end) {
pos = i
val token = elements.firstNonNull {
val t = this.it()
when {
t == null -> null // not match
i + t.length > end -> null // overrun detected
else -> t
}
}
if (token == null) {
++i
continue
}
closeTextToken(i)
push(i, token)
i += token.length
lastEnd = i
}
closeTextToken(end)
return spanList
}
}
private val reLineComment = """\A//.*"""
.asciiPattern()
private val reBlockComment = """\A/\*.*?\*/"""
.asciiPattern(Pattern.DOTALL)
private val reNumber = """\A[\-+]?[\d.]+"""
.asciiPattern()
private val reLabel = """\A@([A-Z_-][A-Z0-9_-]*)"""
.asciiPattern(Pattern.CASE_INSENSITIVE)
private val reKeyword = """\A([A-Z_-][A-Z0-9_-]*)([ \t]*\()?"""
.asciiPattern(Pattern.CASE_INSENSITIVE)
private val reContainsAlpha = """[A-Za-z_]"""
.asciiPattern()
private const val charH80 = 0x80.toChar()
private val elements = arrayOf<Env.() -> Token?>(
// マルチバイト文字をまとめて読み飛ばす
{
var s = pos
while (s < end && source[s] >= charH80) {
++s
}
when {
s > pos -> Token(length = s - pos)
else -> null
}
},
// 空白と改行をまとめて読み飛ばす
{
var s = pos
while (s < end && source[s] <= ' ') {
++s
}
when {
s > pos -> Token(length = s - pos)
else -> null
}
},
// comment
{
val match = remainMatcher(reLineComment)
when {
!match.find() -> null
else -> Token(length = match.end() - match.start(), comment = true)
}
},
// block comment
{
val match = remainMatcher(reBlockComment)
when {
!match.find() -> null
else -> Token(length = match.end() - match.start(), comment = true)
}
},
// string
{
val beginChar = source[pos]
if (!stringStart[beginChar.code]) return@arrayOf null
var i = pos + 1
while (i < end) {
val char = source[i++]
if (char == beginChar) {
break // end
} else if (char == '\n' || i >= end) {
i = 0 // not string literal
break
} else if (char == '\\' && i < end) {
++i // \" では閉じないようにする
}
}
when {
i <= pos -> null
else -> Token(length = i - pos, color = 0xe96900)
}
},
// regexp
{
if (source[pos] != '/') return@arrayOf null
val regexp = StringBuilder()
var i = pos + 1
while (i < end) {
val char = source[i++]
if (char == '/') {
break
} else if (char == '\n' || i >= end) {
i = 0 // not closed
break
} else {
regexp.append(char)
if (char == '\\' && i < end) {
regexp.append(source[i++])
}
}
}
when {
i == 0 -> null
regexp.isEmpty() -> null
regexp.first() == ' ' && regexp.last() == ' ' -> null
else -> Token(length = regexp.length + 2, color = 0xe9003f)
}
},
// label
{
// 直前に識別子があればNG
val prev = if (pos <= 0) null else source[pos - 1]
if (prev?.isLetterOrDigit() == true) return@arrayOf null
val match = remainMatcher(reLabel)
if (!match.find()) return@arrayOf null
val matchEnd = match.end()
when {
// @user@host のように直後に@が続くのはNG
matchEnd < end && source[matchEnd] == '@' -> null
else -> Token(length = match.end() - pos, color = 0xe9003f)
}
},
// number
{
val prev = if (pos <= 0) null else source[pos - 1]
if (prev?.isLetterOrDigit() == true) return@arrayOf null
val match = remainMatcher(reNumber)
when {
!match.find() -> null
else -> Token(length = match.end() - pos, color = 0xae81ff)
}
},
// method, property, keyword
{
// 直前の文字が識別子に使えるなら識別子の開始とはみなさない
val prev = if (pos <= 0) null else source[pos - 1]
if (prev?.isLetterOrDigit() == true || prev == '_') return@arrayOf null
val match = remainMatcher(reKeyword)
if (!match.find()) return@arrayOf null
val kw = match.groupEx(1)!!
val bracket = match.groupEx(2) // may null
when {
// 英数字や_を含まないキーワードは無視する
// -moz-foo- や __ はキーワードだが、 - や -- はキーワードではない
!reContainsAlpha.matcher(kw).find() -> null
// メソッド呼び出しは対象が変数かプロパティかに関わらずメソッドの色になる
bracket?.isNotEmpty() == true ->
Token(length = kw.length, color = 0x8964c1, italic = true)
// 変数や定数ではなくプロパティならプロパティの色になる
prev == '.' -> Token(length = kw.length, color = 0xa71d5d)
// 予約語ではない
// 強調表示しないが、識別子単位で読み飛ばす
!keywords.contains(kw) -> Token(length = kw.length)
else -> when (kw) {
// 定数
"true", "false", "null", "nil", "undefined", "NaN" ->
Token(length = kw.length, color = 0xae81ff)
// その他の予約語
else -> Token(length = kw.length, color = 0x2973b7)
}
}
},
// symbol
{
val c = source[pos]
when {
symbolMap.get(c.code, false) ->
Token(length = 1, color = 0x42b983)
c == '-' ->
Token(length = 1, color = 0x42b983)
else -> null
}
}
)
fun parse(source: String) = Env(source, 0, source.length).parse()
}

View File

@ -0,0 +1,18 @@
package jp.juggler.subwaytooter.mfm
import java.util.*
// マークダウン要素
class Node(
val type: NodeType, // ノード種別
val args: Array<String> = emptyArray(), // 引数
parentNode: Node?,
) {
val childNodes = LinkedList<Node>()
val quoteNest: Int = (parentNode?.quoteNest ?: 0) + when (type) {
NodeType.QUOTE_BLOCK, NodeType.QUOTE_INLINE -> 1
else -> 0
}
}

View File

@ -0,0 +1,15 @@
package jp.juggler.subwaytooter.mfm
// マークダウン要素の出現位置
class NodeDetected(
val node: Node,
val start: Int, // テキスト中の開始位置
val end: Int, // テキスト中の終了位置
val textInside: String, // 内部範囲。親から継承する場合もあるし独自に作る場合もある
val startInside: Int, // 内部範囲の開始位置
private val lengthInside: Int, // 内部範囲の終了位置
) {
val endInside: Int
get() = startInside + lengthInside
}

View File

@ -0,0 +1,124 @@
package jp.juggler.subwaytooter.mfm
import jp.juggler.subwaytooter.mfm.MisskeyMarkdownDecoderExt.mapAllowInside
import jp.juggler.subwaytooter.mfm.MisskeyMarkdownDecoderExt.nodeParserMap
import jp.juggler.util.firstNonNull
import java.util.*
import java.util.regex.Pattern
class NodeParseEnv(
val useFunction: Boolean,
private val parentNode: Node,
val text: String,
start: Int,
val end: Int,
) {
private val childNodes = parentNode.childNodes
private val allowInside: HashSet<NodeType> =
mapAllowInside[parentNode.type] ?: hashSetOf()
// 直前のノードの終了位置
internal var lastEnd = start
// 注目中の位置
internal var pos: Int = 0
// 直前のードの終了位置から次のードの開始位置の手前までをresultに追加する
private fun closeText(endText: Int) {
val length = endText - lastEnd
if (length <= 0) return
val textInside = text.substring(lastEnd, endText)
childNodes.add(Node(NodeType.TEXT, arrayOf(textInside), null))
}
fun remainMatcher(pattern: Pattern) =
MatcherCache.matcher(pattern, text, pos, end)
fun parseInside() {
if (allowInside.isEmpty()) return
var i = lastEnd //スキャン中の位置
while (i < end) {
// 注目位置の文字に関連するパーサー
val lastParsers = nodeParserMap[text[i].code]
if (lastParsers == null) {
++i
continue
}
// パーサー用のパラメータを用意する
// 部分文字列のコストは高くないと信じたい
pos = i
val detected = lastParsers.firstNonNull {
val d = this.it()
if (d == null) {
null
} else {
val n = d.node
if (!allowInside.contains(d.node.type)) {
MisskeyMarkdownDecoder.log.w(
"not allowed : ${parentNode.type} => ${n.type} ${
text.substring(
d.start,
d.end
)
}"
)
null
} else {
d
}
}
}
if (detected == null) {
++i
continue
}
closeText(detected.start)
childNodes.add(detected.node)
i = detected.end
lastEnd = i
NodeParseEnv(
useFunction,
detected.node,
detected.textInside,
detected.startInside,
detected.endInside
).parseInside()
}
closeText(end)
}
fun makeDetected(
type: NodeType,
args: Array<String>,
start: Int,
end: Int,
textInside: String,
startInside: Int,
lengthInside: Int,
): NodeDetected {
val node = Node(type, args, parentNode)
if (MisskeyMarkdownDecoder.DEBUG) MisskeyMarkdownDecoder.log.d(
"NodeDetected: ${node.type} inside=${
textInside.substring(startInside, startInside + lengthInside)
}"
)
return NodeDetected(
node,
start,
end,
textInside,
startInside,
lengthInside
)
}
}

View File

@ -0,0 +1,346 @@
package jp.juggler.subwaytooter.mfm
import jp.juggler.subwaytooter.mfm.MisskeyMarkdownDecoderExt.trimBlock
import jp.juggler.util.encodePercent
import jp.juggler.util.notEmpty
// ノード種別および種別ごとのレンダリング関数
enum class NodeType(val render: SpanOutputEnv.(Node) -> Unit) {
TEXT({
appendText(it.args[0], decodeEmoji = true)
}),
EMOJI({
val code = it.args[0]
if (code.isNotEmpty()) {
appendText(":$code:", decodeEmoji = true)
}
}),
MENTION({
appendMention(it.args[0], it.args[1].notEmpty())
}),
LATEX({
if (decorationEnabled && showUnsupportedMarkup) {
fireRenderChildNodes(it)
}
}),
HASHTAG({
val linkHelper = linkHelper
val tag = it.args[0]
if (tag.isNotEmpty() && linkHelper != null) {
appendLink(
"#$tag",
"https://${linkHelper.apiHost.ascii}/tags/" + tag.encodePercent()
)
}
}),
CODE_INLINE({
val text = it.args[0]
if (!decorationEnabled) {
appendText(text)
} else {
val sp = MisskeySyntaxHighlighter.parse(text)
appendText(text)
spanList.addWithOffset(sp, start)
spanList.addLast(start, sb.length, android.text.style.BackgroundColorSpan(0x40808080))
spanList.addLast(
start, sb.length,
jp.juggler.util.fontSpan(android.graphics.Typeface.MONOSPACE)
)
}
}),
URL({
val url = it.args[0]
if (url.isNotEmpty()) {
appendLink(url, url, allowShort = true)
}
}),
CODE_BLOCK({
if (!decorationEnabled) {
appendText(it.args[0])
} else {
closePreviousBlock()
val text = trimBlock(it.args[0])
val sp = MisskeySyntaxHighlighter.parse(text)
appendText(text)
spanList.addWithOffset(sp, start)
spanList.addLast(start, sb.length, android.text.style.BackgroundColorSpan(0x40808080))
spanList.addLast(start, sb.length, android.text.style.RelativeSizeSpan(0.7f))
spanList.addLast(
start, sb.length,
jp.juggler.util.fontSpan(android.graphics.Typeface.MONOSPACE)
)
closeBlock()
}
}),
QUOTE_INLINE({
if (!decorationEnabled) {
appendText(it.args[0])
} else {
val text = trimBlock(it.args[0])
appendText(text)
spanList.addLast(
start,
sb.length,
android.text.style.BackgroundColorSpan(0x20808080)
)
spanList.addLast(
start,
sb.length,
jp.juggler.util.fontSpan(android.graphics.Typeface.defaultFromStyle(android.graphics.Typeface.ITALIC))
)
}
}),
SEARCH({
if (!decorationEnabled) {
appendText(it.args[0])
appendText(" ")
appendText(context.getString(jp.juggler.subwaytooter.R.string.search))
} else {
closePreviousBlock()
val text = it.args[0]
val kw_start = sb.length // キーワードの開始位置
appendText(text)
appendText(" ")
start = sb.length // 検索リンクの開始位置
appendLink(
context.getString(jp.juggler.subwaytooter.R.string.search),
"https://www.google.co.jp/search?q=${text.encodePercent()}"
)
spanList.addLast(kw_start, sb.length, android.text.style.RelativeSizeSpan(1.2f))
closeBlock()
}
}),
BIG({
if (!decorationEnabled) {
fireRenderChildNodes(it)
} else {
val start = this.start
fireRenderChildNodes(it)
spanList.addLast(
start, sb.length,
jp.juggler.subwaytooter.span.MisskeyBigSpan(font_bold)
)
}
}),
BOLD({
if (!decorationEnabled) {
fireRenderChildNodes(it)
} else {
val start = this.start
fireRenderChildNodes(it)
spanList.addLast(start, sb.length, jp.juggler.util.fontSpan(font_bold))
}
}),
STRIKE({
if (!decorationEnabled) {
fireRenderChildNodes(it)
} else {
val start = this.start
fireRenderChildNodes(it)
spanList.addLast(start, sb.length, android.text.style.StrikethroughSpan())
}
}),
SMALL({
if (!decorationEnabled) {
fireRenderChildNodes(it)
} else {
val start = this.start
fireRenderChildNodes(it)
spanList.addLast(start, sb.length, android.text.style.RelativeSizeSpan(0.7f))
}
}),
FUNCTION({
if (decorationEnabled && showUnsupportedMarkup) {
val name = it.args.elementAtOrNull(0)
appendText("[")
appendText(name ?: "")
appendText(" ")
fireRenderChildNodes(it)
appendText("]")
}
//
}),
ITALIC({
if (!decorationEnabled) {
fireRenderChildNodes(it)
} else {
val start = this.start
fireRenderChildNodes(it)
spanList.addLast(
start, sb.length,
jp.juggler.util.fontSpan(android.graphics.Typeface.defaultFromStyle(android.graphics.Typeface.ITALIC))
)
}
}),
MOTION({
if (!decorationEnabled) {
fireRenderChildNodes(it)
} else {
val start = this.start
fireRenderChildNodes(it)
spanList.addFirst(
start,
sb.length,
jp.juggler.subwaytooter.span.MisskeyMotionSpan(jp.juggler.subwaytooter.ActMain.timelineFont)
)
}
}),
LINK({
val url = it.args[1]
// val silent = data?.get(2)
// silentはプレビュー表示を抑制するが、Subwayにはもともとないので関係なかった
if (url.isNotEmpty()) {
if (!decorationEnabled) {
fireRenderChildNodes(it)
} else {
val start = this.start
fireRenderChildNodes(it)
val linkHelper = options.linkHelper
if (linkHelper != null) {
val linkInfo = jp.juggler.subwaytooter.span.LinkInfo(
url = url,
tag = options.linkTag,
ac = jp.juggler.subwaytooter.api.entity.TootAccount.getAcctFromUrl(url)
?.let { acct -> jp.juggler.subwaytooter.table.AcctColor.load(acct) },
caption = sb.substring(start, sb.length)
)
spanList.addFirst(
start, sb.length,
jp.juggler.subwaytooter.span.MyClickableSpan(linkInfo)
)
}
}
}
}),
TITLE({
if (!decorationEnabled) {
fireRenderChildNodes(it) // 改行を含まないことが分かっている
} else {
closePreviousBlock()
val start = this.start
fireRenderChildNodes(it) // 改行を含まないことが分かっている
spanList.addLast(
start,
sb.length,
android.text.style.AlignmentSpan.Standard(android.text.Layout.Alignment.ALIGN_CENTER)
)
spanList.addLast(
start,
sb.length,
android.text.style.BackgroundColorSpan(0x20808080)
)
spanList.addLast(start, sb.length, android.text.style.RelativeSizeSpan(1.5f))
closeBlock()
}
}),
CENTER({
if (!decorationEnabled) {
fireRenderChildNodes(it)
} else {
closePreviousBlock()
val start = this.start
fireRenderChildNodes(it)
when {
it.quoteNest > 0 -> {
// 引用ネストの内部ではセンタリングさせると引用マーカーまで動いてしまうので
// センタリングが機能しないようにする
}
else -> spanList.addLast(
start,
sb.length,
android.text.style.AlignmentSpan.Standard(
android.text.Layout.Alignment.ALIGN_CENTER
)
)
}
closeBlock()
}
}),
QUOTE_BLOCK({
if (!decorationEnabled) {
fireRenderChildNodes(it)
} else {
closePreviousBlock()
val start = this.start
// 末尾にある空白のテキストノードを除去する
while (it.childNodes.isNotEmpty()) {
val last = it.childNodes.last()
if (last.type == TEXT && last.args[0].isBlank()) {
it.childNodes.removeLast()
} else {
break
}
}
fireRenderChildNodes(it)
val bg_color =
MisskeyMarkdownDecoder.quoteNestColors[it.quoteNest % MisskeyMarkdownDecoder.quoteNestColors.size]
// TextView の文字装飾では「ブロック要素の入れ子」を表現できない
// 内容の各行の始端に何か追加するというのがまずキツい
// しかし各行の頭に引用マークをつけないと引用のネストで意味が通じなくなってしまう
val tmp = sb.toString()
//log.d("QUOTE_BLOCK tmp=${tmp} start=$start end=${tmp.length}")
for (i in tmp.length - 1 downTo start) {
val prevChar = when (i) {
start -> '\n'
else -> tmp[i - 1]
}
//log.d("QUOTE_BLOCK prevChar=${ String.format("%x",prevChar.toInt())}")
if (prevChar == '\n') {
//log.d("QUOTE_BLOCK insert! i=$i")
sb.insert(i, "> ")
spanList.insert(i, 2)
spanList.addLast(
i, i + 1,
android.text.style.BackgroundColorSpan(bg_color)
)
}
}
spanList.addLast(
start,
sb.length,
jp.juggler.util.fontSpan(android.graphics.Typeface.defaultFromStyle(android.graphics.Typeface.ITALIC))
)
closeBlock()
}
}),
ROOT({
fireRenderChildNodes(it)
}),
}

View File

@ -0,0 +1,74 @@
package jp.juggler.subwaytooter.mfm
import android.text.SpannableStringBuilder
import android.text.Spanned
import java.util.*
// 文字装飾の指定を溜めておいてノードの親子関係に応じて順序を調整して、最後にまとめて適用する
class SpanList {
private class SpanPos(var start: Int, var end: Int, val span: Any)
private val list = LinkedList<SpanPos>()
fun setSpan(sb: SpannableStringBuilder) =
list.forEach { sb.setSpan(it.span, it.start, it.end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) }
fun addAll(other: SpanList) = list.addAll(other.list)
fun addWithOffset(src: SpanList, offset: Int) {
src.list.forEach { addLast(it.start + offset, it.end + offset, it.span) }
}
fun addFirst(start: Int, end: Int, span: Any) {
when {
start == end -> {
// empty span allowed
}
start > end -> {
MisskeyMarkdownDecoder.log.e("SpanList.add: range error! start=$start,end=$end,span=$span")
}
else -> {
list.addFirst(SpanPos(start, end, span))
}
}
}
fun addLast(start: Int, end: Int, span: Any) {
when {
start == end -> {
// empty span allowed
}
start > end -> {
MisskeyMarkdownDecoder.log.e("SpanList.add: range error! start=$start,end=$end,span=$span")
}
else -> {
list.addLast(SpanPos(start, end, span))
}
}
}
fun insert(offset: Int, length: Int) {
for (sp in list) {
when {
sp.end <= offset -> {
// nothing to do
}
sp.start <= offset -> {
sp.end += length
}
else -> {
sp.start += length
sp.end += length
}
}
}
}
}

View File

@ -0,0 +1,262 @@
package jp.juggler.subwaytooter.mfm
import android.content.Context
import android.text.Spanned
import android.text.style.RelativeSizeSpan
import jp.juggler.subwaytooter.ActMain
import jp.juggler.subwaytooter.App1
import jp.juggler.subwaytooter.PrefB
import jp.juggler.subwaytooter.api.entity.Acct
import jp.juggler.subwaytooter.api.entity.EntityId
import jp.juggler.subwaytooter.api.entity.TootMention
import jp.juggler.subwaytooter.span.HighlightSpan
import jp.juggler.subwaytooter.span.LinkInfo
import jp.juggler.subwaytooter.span.MyClickableSpan
import jp.juggler.subwaytooter.span.SvgEmojiSpan
import jp.juggler.subwaytooter.table.AcctColor
import jp.juggler.subwaytooter.table.HighlightWord
import jp.juggler.subwaytooter.util.*
import java.util.*
// 装飾つきテキストの出力時に使うデータの集まり
class SpanOutputEnv(
val options: DecodeOptions,
val sb: SpannableStringBuilderEx,
) {
val context: Context = options.context ?: error("missing context")
val decorationEnabled = PrefB.bpMfmDecorationEnabled(context)
val showUnsupportedMarkup = PrefB.bpMfmDecorationShowUnsupportedMarkup(context)
val font_bold = ActMain.timeline_font_bold
val linkHelper: LinkHelper? = options.linkHelper
var spanList = SpanList()
var start = 0
fun fireRender(node: Node): SpanList {
val spanList = SpanList()
this.spanList = spanList
this.start = sb.length
node.type.render.invoke(this, node)
return spanList
}
internal fun fireRenderChildNodes(parent: Node): SpanList {
val parent_result = this.spanList
parent.childNodes.forEach {
val child_result = fireRender(it)
parent_result.addAll(child_result)
}
this.spanList = parent_result
return parent_result
}
// 直前の文字が改行文字でなければ改行する
fun closePreviousBlock() {
if (start > 0 && sb[start - 1] != '\n') {
sb.append('\n')
start = sb.length
}
}
fun closeBlock() {
if (sb.length > 0 && sb[sb.length - 1] != '\n') {
val start = sb.length
sb.append('\n')
val end = sb.length
sb.setSpan(RelativeSizeSpan(0.1f), start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
}
}
private fun applyHighlight(start: Int, end: Int) {
val list = options.highlightTrie?.matchList(sb, start, end)
if (list != null) {
for (range in list) {
val word = HighlightWord.load(range.word) ?: continue
spanList.addLast(
range.start,
range.end,
HighlightSpan(
word.color_fg,
word.color_bg
)
)
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
}
}
}
// テキストを追加する
fun appendText(text: CharSequence, decodeEmoji: Boolean = false) {
val start = sb.length
if (decodeEmoji) {
sb.append(options.decodeEmoji(text.toString()))
} else {
sb.append(text)
}
applyHighlight(start, sb.length)
}
// URL中のテキストを追加する
private fun appendLinkText(displayUrl: String, href: String) {
when {
// 添付メディアのURLなら絵文字に変えてしまう
options.isMediaAttachment(href) -> {
// リンクの一部に絵文字がある場合、絵文字スパンをセットしてからリンクをセットする
val start = sb.length
sb.append(href)
spanList.addFirst(
start,
sb.length,
SvgEmojiSpan(
context,
"emj_1f5bc.svg",
scale = 1f
),
)
}
else -> appendText(
HTMLDecoder.shortenUrl(
displayUrl
)
)
}
}
// リンクを追加する
fun appendLink(
text: String,
url: String,
allowShort: Boolean = false,
mention: TootMention? = null,
) {
when {
allowShort -> appendLinkText(text, url)
else -> appendText(text)
}
val fullAcct = if (!text.startsWith('@')) {
null
//リンクキャプションがメンション風でないならメンションとは扱わない
} else {
// 通称と色を調べる
getFullAcctOrNull(
rawAcct = Acct.parse(
text.substring(
1
)
),
url = url,
options.linkHelper,
options.mentionDefaultHostDomain,
)
}
val linkInfo = LinkInfo(
caption = text,
url = url,
ac = fullAcct?.let {
AcctColor.load(
fullAcct
)
},
tag = options.linkTag,
mention = mention
)
// リンクの一部にハイライトがある場合、リンクをセットしてからハイライトをセットしないとクリック判定がおかしくなる。
spanList.addFirst(
start, sb.length,
MyClickableSpan(linkInfo)
)
}
private fun prepareMentions(): ArrayList<TootMention> {
var mentions = sb.mentions
if (mentions == null) {
mentions = ArrayList()
sb.mentions = mentions
}
return mentions
}
fun appendMention(
username: String,
strHost: String?,
) {
// ユーザが記述したacct
val rawAcct = Acct.parse(username, strHost)
val linkHelper = linkHelper
if (linkHelper == null) {
appendText("@${rawAcct.pretty}")
return
}
// 長いacct
// MFMでは投稿者のドメインを補うのはサーバ側の仕事の筈なので、options.mentionDefault…は見ない
val fullAcct = rawAcct.followHost(linkHelper.apDomain)
// mentionsメタデータに含まれるacct
// ユーザの記述に因らず、サーバのホスト名同じなら短い、そうでなければ長いメンション
val shortAcct = when {
linkHelper.matchHost(fullAcct.host) -> Acct.parse(username)
else -> fullAcct
}
// リンク表記はユーザの記述やアプリ設定の影響を受ける
val caption = "@${
when {
PrefB.bpMentionFullAcct(
App1.pref
) -> fullAcct
else -> rawAcct
}.pretty
}"
var mention: TootMention? = null
val url = when (strHost) {
// https://github.com/syuilo/misskey/pull/3603
"github.com", "twitter.com" ->
"https://$strHost/$username" // no @
"gmail.com" ->
"mailto:$username@$strHost"
else ->
// MFMはメンションからユーザのURIを調べる正式な方法がない
// たとえば @group_dev_jp@gup.pe の正式なURLは https://gup.pe/u/group_dev_jp
// だが、 misskey.io ではメンションのリンク先は https://misskey.io/@group_dev_jp@gup.pe になる
"https://${fullAcct.host?.ascii}/@$username"
.also { url ->
val mentions = prepareMentions()
mention = mentions.find { m -> m.acct == shortAcct }
if (mention == null) {
val newMention =
TootMention(
EntityId.DEFAULT,
url,
shortAcct.ascii,
username
)
mentions.add(newMention)
mention = newMention
}
}
}
appendLink(caption, url, mention = mention)
}
}

View File

@ -0,0 +1,10 @@
package jp.juggler.subwaytooter.mfm
import android.text.SpannableStringBuilder
import jp.juggler.subwaytooter.api.entity.TootMention
import java.util.ArrayList
// デコード結果にはメンションの配列を含む。TootStatusのパーサがこれを回収する。
class SpannableStringBuilderEx(
var mentions: ArrayList<TootMention>? = null,
) : SpannableStringBuilder()

View File

@ -460,44 +460,42 @@ class CustomEmojiCache(
}
private fun decodeAPNG(data: ByteArray, url: String): ApngFrames? {
val errors = ArrayList<Throwable>()
try {
// APNGをデコード
val x = ApngFrames.parse(64) { ByteArrayInputStream(data) }
if (x != null) return x
// fall thru
error("ApngFrames.parse returns null.")
} catch (ex: Throwable) {
if (DEBUG) log.trace(ex)
log.e(ex, "PNG decode failed. $url ")
errors.add(ex)
}
// 通常のビットマップでのロードを試みる
try {
val b = decodeBitmap(data, 128)
if (b != null) {
if (DEBUG) log.d("bitmap decoded.")
return ApngFrames(b)
} else {
log.e("Bitmap decode returns null. $url")
}
// fall thru
if (b != null) return ApngFrames(b)
error("decodeBitmap returns null.")
} catch (ex: Throwable) {
log.e(ex, "Bitmap decode failed. $url")
if (DEBUG) log.trace(ex)
errors.add(ex)
}
// SVGのロードを試みる
try {
val b = decodeSVG(url, data, 128.toFloat())
if (b != null) {
if (DEBUG) log.d("SVG decoded.")
return ApngFrames(b)
}
// fall thru
if (b != null) return ApngFrames(b)
error("decodeSVG returns null.")
} catch (ex: Throwable) {
log.e(ex, "SVG decode failed. $url")
if (DEBUG) log.trace(ex)
errors.add(ex)
}
// 全部ダメだった
log.e("decode failed. url=$url, errors=${
errors.joinToString(", ") { "${it.javaClass} ${it.message}" }
}")
return null
}
@ -514,10 +512,8 @@ class CustomEmojiCache(
BitmapFactory.decodeByteArray(data, 0, data.size, options)
var w = options.outWidth
var h = options.outHeight
if (w <= 0 || h <= 0) {
log.e("can't decode bounds.")
return null
}
if (w <= 0 || h <= 0) error("decodeBitmap: can't decode bounds.")
var bits = 0
while (w > pixelMax || h > pixelMax) {
++bits

View File

@ -10,6 +10,7 @@ import jp.juggler.subwaytooter.App1
import jp.juggler.subwaytooter.PrefB
import jp.juggler.subwaytooter.R
import jp.juggler.subwaytooter.api.entity.*
import jp.juggler.subwaytooter.mfm.MisskeyMarkdownDecoder
import jp.juggler.subwaytooter.span.*
import jp.juggler.subwaytooter.table.AcctColor
import jp.juggler.subwaytooter.table.HighlightWord

View File

@ -30,6 +30,13 @@ fun <T : Any> MutableCollection<T>.removeFirst(check: (T) -> Boolean): T? {
}
return null
}
// 配列中の要素をラムダ式で変換して、戻り値が非nullならそこで処理を打ち切る
inline fun <T, V> Array<out T>.firstNonNull(predicate: (T) -> V?): V? {
for (element in this) return predicate(element) ?: continue
return null
}
//
//object Utils {
//

View File

@ -1097,4 +1097,6 @@
<string name="emoji_category_others">その他</string>
<string name="account_picker_block">どのアカウントで %1$s をブロックしますか\?</string>
<string name="account_picker_mute">どのアカウントで %1$s をミュートしますか\?</string>
</resources>
<string name="mfm_decoration_enabled">(Misskey)テキスト装飾を表示する</string>
<string name="mfm_show_unsupported_markup">(Misskey)未対応のマークアップを表示する</string>
</resources>

View File

@ -1108,4 +1108,6 @@
<string name="confirm_reaction_to_pleroma">Reaction custom emoji %1$s from %2$s to server %3$s that may not support custom emoji reaction. Continue?</string>
<string name="multi_window_post">Multi window for post activity (experimental)</string>
<string name="many_window_post">Many window for post activity (experimental)</string>
<string name="mfm_decoration_enabled">(Misskey)Show text decorations</string>
<string name="mfm_show_unsupported_markup">(Misskey)Show unsupported markups</string>
</resources>