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

954 lines
24 KiB
Kotlin
Raw Normal View History

package jp.juggler.subwaytooter.util
2018-08-24 02:26:45 +02:00
import android.content.Context
import android.graphics.Color
import android.graphics.Typeface
import android.net.Uri
import android.text.Layout
import android.text.Spannable
import android.text.SpannableStringBuilder
import android.text.Spanned
import android.text.style.AlignmentSpan
import android.text.style.BackgroundColorSpan
import android.text.style.ForegroundColorSpan
import android.text.style.RelativeSizeSpan
2018-08-24 02:26:45 +02:00
import android.util.SparseArray
import android.util.SparseBooleanArray
import jp.juggler.subwaytooter.ActMain
import jp.juggler.subwaytooter.App1
import jp.juggler.subwaytooter.Pref
import jp.juggler.subwaytooter.R
import jp.juggler.subwaytooter.api.entity.EntityIdLong
import jp.juggler.subwaytooter.api.entity.TootMention
2018-08-22 03:05:54 +02:00
import jp.juggler.subwaytooter.span.*
import jp.juggler.subwaytooter.table.HighlightWord
import uk.co.chrisjenx.calligraphy.CalligraphyTypefaceSpan
import java.util.regex.Pattern
// 指定した文字数までの部分文字列を返す
private fun String.safeSubstring(count : Int, offset : Int = 0) = when {
offset + count <= length -> this.substring(offset, count)
else -> this.substring(offset, length)
}
// 配列中の要素をラムダ式で変換して、戻り値が非nullならそこで処理を打ち切る
private inline fun <T, V> Array<out T>.firstNonNull(predicate : (T) -> V?) : V? {
for(element in this) return predicate(element) ?: continue
return null
}
// ```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 { k -> k.toUpperCase() })
// Snake
addAll(_keywords.map { k -> k[0].toUpperCase() + k.substring(1) })
add("NaN")
// 識別子に対して既存の名前と一致するか調べるようになったので、もはやソートの必要はない
}
private val symbolMap = SparseBooleanArray().apply {
for(c in "=+-*/%~^&|><!?") {
this.put(c.toInt(), true)
}
}
// 文字列リテラルの開始文字のマップ
private val stringStart = SparseBooleanArray().apply {
for(c in "\"'`") {
this.put(c.toInt(), 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 sb = SpannableStringBuilder(source)
// 残り部分
var remain : String = source
private set
// スキャン位置
var pos : Int = 0
set(value) {
field = value
remain = source.substring(value)
}
fun push(start : Int, token : Token) {
val end = start + token.length
if(token.comment) {
sb.setSpan(
ForegroundColorSpan(Color.BLACK or 0x808000)
, start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
)
} else {
var c = token.color
if(c != 0) {
if(c < 0x1000000) {
c = c or Color.BLACK
}
sb.setSpan(
ForegroundColorSpan(c)
, start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
)
}
if(token.italic) {
sb.setSpan(
CalligraphyTypefaceSpan(Typeface.defaultFromStyle(Typeface.ITALIC))
, start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
)
}
}
}
fun parse() : SpannableStringBuilder {
var lastEnd = 0
fun closeTextToken(textEnd : Int) {
val length = textEnd - lastEnd
if(length > 0) {
push(lastEnd, Token(length = length))
lastEnd = textEnd
}
}
while(remain.isNotEmpty()) {
val token = elements.firstNonNull { this.it() }
if(token == null) {
++ pos
} else {
closeTextToken(pos)
push(pos, token)
this.pos += token.length
lastEnd = pos
}
}
closeTextToken(pos)
return sb
}
}
private val reLineComment = Pattern.compile("""\A//.*""")
private val reBlockComment = Pattern.compile("""\A/\*.*?\*/""", Pattern.DOTALL)
private val reNumber = Pattern.compile("""\A[+-]?[\d.]+""")
private val reLabel = Pattern.compile("""\A@([A-Z_-][A-Z0-9_-]*)""", Pattern.CASE_INSENSITIVE)
private val reKeyword =
Pattern.compile("""\A([A-Z_-][A-Z0-9_-]*)([ \t]*\()?""", Pattern.CASE_INSENSITIVE)
private val elements = arrayOf<Env.() -> Token?>(
// comment
{
val match = reLineComment.matcher(remain)
when {
! match.find() -> null
else -> Token(length = match.end(), comment = true)
}
},
// block comment
{
val match = reBlockComment.matcher(remain)
when {
! match.find() -> null
else -> Token(length = match.end(), comment = true)
}
},
// string
{
val beginChar = remain[0]
if(! stringStart[beginChar.toInt()]) return@arrayOf null
var len = 1
while(len < remain.length) {
val char = remain[len ++]
if(char == beginChar) {
break // end
} else if(char == '\n' || len >= remain.length) {
len = 0 // not string literal
break
} else if(char == '\\' && len < remain.length) {
++ len // \" では閉じないようにする
}
}
when(len) {
0 -> null
else -> Token(length = len, color = 0xe96900)
}
},
// regexp
{
if(remain[0] != '/') return@arrayOf null
val regexp = StringBuilder()
var notClosed = false
var i = 1
while(i < remain.length) {
val char = remain[i ++]
if(char == '/') {
break
} else if(char == '\n' || i >= remain.length) {
notClosed = true
break
} else {
regexp.append(char)
if(char == '\\' && i < remain.length) {
regexp.append(remain[i ++])
}
}
}
when {
notClosed -> null
regexp.isEmpty() -> null
regexp[0] == ' ' && regexp[regexp.length - 1] == ' ' -> 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
2018-08-24 02:26:45 +02:00
val match = reLabel.matcher(remain)
if(! match.find()) return@arrayOf null
2018-08-24 02:26:45 +02:00
val end = match.end()
2018-08-24 02:26:45 +02:00
when {
// @user@host のように直後に@が続くのはNG
remain.length > end && remain[end] == '@' -> null
2018-08-24 02:26:45 +02:00
else -> Token(length = match.end(), color = 0xe9003f)
}
},
// number
{
val prev = if(pos <= 0) null else source[pos - 1]
if(prev?.isLetterOrDigit() == true) return@arrayOf null
val match = reNumber.matcher(remain)
when {
! match.find() -> null
else -> Token(length = match.end(), 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 = reKeyword.matcher(remain)
if(! match.find()) return@arrayOf null
val kw = match.group(1)
val bracket = match.group(2)
when {
// メソッド呼び出しは対象が変数かプロパティかに関わらずメソッドの色になる
bracket?.isNotEmpty() == true ->
Token(length = kw.length, color = 0x8964c1, italic = true)
// 変数や定数ではなくプロパティならプロパティの色になる
2018-08-24 02:26:45 +02:00
prev == '.' -> Token(length = kw.length, color = 0xa71d5d)
// 予約語ではない
// 強調表示しないが、識別子単位で読み飛ばす
! keywords.contains(kw) -> Token(length = kw.length)
else -> when(kw) {
2018-08-24 02:26:45 +02:00
// 定数
2018-08-24 02:26:45 +02:00
"true", "false", "null", "nil", "undefined", "NaN" ->
Token(length = kw.length, color = 0xae81ff)
2018-08-24 02:26:45 +02:00
// その他の予約語
2018-08-24 02:26:45 +02:00
else -> Token(length = kw.length, color = 0x2973b7)
}
}
},
// symbol
{
when {
symbolMap.get(remain[0].toInt(), false) ->
Token(length = 1, color = 0x42b983)
else -> null
}
}
)
fun parse(source : String) = Env(source = source).parse()
}
object MisskeyMarkdownDecoder {
private val log = LogCategory("MisskeyMarkdownDecoder")
// デコード結果にはメンションの配列を含む。TootStatusのパーサがこれを回収する。
2018-08-24 02:26:45 +02:00
class SpannableStringBuilderEx : SpannableStringBuilder() {
2018-08-24 02:26:45 +02:00
var mentions : ArrayList<TootMention>? = null
}
// マークダウン要素のデコード時に使う作業変数をまとめたクラス
private class SpanOutputEnv(val options : DecodeOptions, val sb : SpannableStringBuilderEx) {
val context : Context = options.context ?: error("missing context")
2018-08-24 02:26:45 +02:00
val font_bold = ActMain.timeline_font_bold
var start = 0
var nodeSource : String = ""
var data : Array<String> = emptyArray()
val linkHelper : LinkHelper? = options.linkHelper
// URLの短縮表記。出力は絵文字スパンを含むかもしれない
fun urlShorter(display_url : String, href : String) : CharSequence = when {
options.isMediaAttachment(href) -> {
// 添付メディアのURLなら絵文字に変えてしまう
val sbTmp = SpannableStringBuilder()
sbTmp.append(href)
2018-08-24 02:26:45 +02:00
val start = 0
val end = sbTmp.length
sbTmp.setSpan(
2018-08-24 02:26:45 +02:00
EmojiImageSpan(context, R.drawable.emj_1f5bc),
start,
end,
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
)
sbTmp
2018-08-24 02:26:45 +02:00
}
else -> try {
2018-08-24 02:26:45 +02:00
val uri = Uri.parse(display_url)
val sbTmp = SpannableStringBuilder()
2018-08-24 02:26:45 +02:00
if(! display_url.startsWith("http")) {
sbTmp.append(uri.scheme)
sbTmp.append("://")
2018-08-24 02:26:45 +02:00
}
sbTmp.append(uri.authority)
2018-08-24 02:26:45 +02:00
val a = uri.encodedPath
val q = uri.encodedQuery
val f = uri.encodedFragment
val remain = a + (if(q == null) "" else "?$q") + if(f == null) "" else "#$f"
if(remain.length > 10) {
sbTmp.append(remain.safeSubstring(10))
sbTmp.append("")
2018-08-24 02:26:45 +02:00
} else {
sbTmp.append(remain)
2018-08-24 02:26:45 +02:00
}
sbTmp
2018-08-24 02:26:45 +02:00
} catch(ex : Throwable) {
log.trace(ex)
display_url
2018-08-24 02:26:45 +02:00
}
}
// 直前の文字が改行文字でなければ改行する
2018-08-24 02:26:45 +02:00
fun closePreviousBlock() {
if(start > 0 && sb[start - 1] != '\n') {
sb.append('\n')
start = sb.length
}
}
// startから現在の終端までにスパンを設定する
fun setSpan(span : Any) =
sb.setSpan(span, start, sb.length, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
2018-08-24 02:26:45 +02:00
// startから現在の終端までに強調表示を設定する
2018-08-24 02:26:45 +02:00
fun setHighlight() {
val list = options.highlightTrie?.matchList(sb, start, sb.length)
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
}
}
}
}
}
// テキストを追加する
fun appendText(text : CharSequence, preventHighlight : Boolean = false) {
2018-08-24 02:26:45 +02:00
sb.append(text)
if(! preventHighlight) setHighlight()
2018-08-24 02:26:45 +02:00
}
// リンクを追加する
2018-08-24 02:26:45 +02:00
fun appendLink(text : String, url : String, allowShort : Boolean = false) {
appendText(
when {
allowShort -> urlShorter(text, url)
else -> text
2018-08-24 02:26:45 +02:00
}
, preventHighlight = true
)
2018-08-24 02:26:45 +02:00
val linkHelper = options.linkHelper
if(linkHelper != null) {
setSpan(
MyClickableSpan(
text
, url
, linkHelper.findAcctColor(url)
, options.linkTag
2018-08-24 02:26:45 +02:00
)
)
}
setHighlight()
}
}
// マークダウン要素のパース時に使う作業変数をまとめたクラス
private class ParseEnv(val text : String) {
2018-08-24 02:26:45 +02:00
var remain : String = ""
var previous : String = ""
var lastEnd = 0 // 直前のノードの終了位置
2018-08-24 02:26:45 +02:00
var pos : Int = 0
set(value) {
field = value
remain = text.substring(pos)
previous = text.substring(lastEnd, pos)
2018-08-24 02:26:45 +02:00
}
var callback : (MisskeyMarkdownDecoder.Node) -> Unit = {}
// 直前のードの終了位置から次のードの開始位置の手前までをresultに追加する
fun closeText(endText : Int) {
val length = endText - lastEnd
if(length > 0) callback(
Node(lastEnd, length, emptyArray()) {
appendText(nodeSource)
}
)
}
fun parse(callback : (Node) -> Unit) {
this.callback = callback
val end = text.length
var i = 0 //スキャン中の位置
while(i < end) {
val lastParsers = nodeParserMap[text[i].toInt()]
if(lastParsers == null) {
++ i
continue
}
pos = i
val node = lastParsers.firstNonNull { this.it() }
if(node == null) {
++ i
continue
}
closeText(node.start)
callback(node)
i=node.start + node.length
lastEnd = i
}
closeText(i)
}
2018-08-24 02:26:45 +02:00
}
// 出現したマークダウン要素
2018-08-24 02:26:45 +02:00
private class Node(
// ソース文字列中の開始位置
var start : Int
// ソース文字列中の長さ
, var length : Int
// 出力時に使うパラメータ
, var data : Array<String>
// 出力処理を行う関数
, var decoder : SpanOutputEnv.() -> Unit
2018-08-24 02:26:45 +02:00
)
// ノードのパースを行う関数をキャプチャパラメータつきで生成する
2018-08-24 02:26:45 +02:00
private fun simpleParser(
pattern : Pattern
, decoder : SpanOutputEnv.() -> Unit
) : ParseEnv.() -> Node? = {
val matcher = pattern.matcher(remain)
2018-08-24 02:26:45 +02:00
when {
! matcher.find() -> null
else -> Node(
pos
, matcher.end()
, arrayOf(matcher.group(1))
, decoder
2018-08-24 02:26:45 +02:00
)
}
}
// (マークダウン要素の特徴的な文字)と(パーサ関数の配列)のマップ
private val nodeParserMap = SparseArray<Array<out ParseEnv.() -> Node?>>().apply {
2018-08-24 02:26:45 +02:00
fun addParser(firstChars : String, vararg nodeParsers : ParseEnv.() -> Node?) {
2018-08-24 02:26:45 +02:00
for(s in firstChars) {
put(s.toInt(), nodeParsers)
}
}
// Quote "...(改行)"
2018-08-24 02:26:45 +02:00
addParser(
"\""
, simpleParser(Pattern.compile("""^"([\s\S]+?)\n"""")) {
closePreviousBlock()
appendText(trimBlock(data[0]))
setSpan(BackgroundColorSpan(0x20808080))
setSpan(CalligraphyTypefaceSpan(Typeface.defaultFromStyle(Typeface.ITALIC)))
appendText("\n")
}
2018-08-24 02:26:45 +02:00
)
// 絵文字 :emoji:
2018-08-24 02:26:45 +02:00
addParser(
":"
, simpleParser(
Pattern.compile("""^:([a-zA-Z0-9+-_]+):""")
) {
val code = data[0]
if(code.isNotEmpty()) {
appendText(options.decodeEmoji(":$code:"))
}
}
2018-08-24 02:26:45 +02:00
)
// モーション (((...))) <motion>...</motion>
val dMotion : SpanOutputEnv.() -> Unit = {
val code = data[0]
appendText(code)
setSpan(MisskeyMotionSpan(ActMain.timeline_font))
}
2018-08-24 02:26:45 +02:00
addParser(
"("
, simpleParser(
Pattern.compile("""^\Q(((\E(.+?)\Q)))\E""")
, dMotion
2018-08-24 02:26:45 +02:00
)
)
2018-08-24 02:26:45 +02:00
addParser(
"<"
, simpleParser(
Pattern.compile("""^<motion>(.+?)</motion>""")
, dMotion
2018-08-24 02:26:45 +02:00
)
)
// ***big*** **bold**
2018-08-24 02:26:45 +02:00
addParser(
"*"
// 処理順序に意味があるので入れ替えないこと
// 記号列が長い順にパースを試す
2018-08-24 02:26:45 +02:00
, simpleParser(
Pattern.compile("""^\Q***\E(.+?)\Q***\E""")
) {
appendText(data[0])
setSpan(MisskeyBigSpan(font_bold))
}
2018-08-24 02:26:45 +02:00
, simpleParser(
Pattern.compile("""^\Q**\E(.+?)\Q**\E""")
) {
appendText(data[0])
setSpan(CalligraphyTypefaceSpan(font_bold))
}
2018-08-24 02:26:45 +02:00
)
// http(s)://....
2018-08-24 02:26:45 +02:00
addParser(
"h"
, simpleParser(
Pattern.compile("""^(https?://[\w/:%#@${'$'}&?!()\[\]~.=+\-]+)""")
) {
val url = data[0]
if(url.isNotEmpty()) {
appendLink(url, url, allowShort = true)
}
}
2018-08-24 02:26:45 +02:00
)
// 検索
2018-08-24 02:26:45 +02:00
val reSearchButton = Pattern.compile(
"""^(検索|\[検索]|Search|\[Search])(\n|${'$'})"""
, Pattern.CASE_INSENSITIVE
)
2018-08-24 02:26:45 +02:00
fun parseSearchPrev(prev : String) : String? {
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 : ParseEnv.() -> Node? = {
val matcher = reSearchButton.matcher(remain)
2018-08-24 02:26:45 +02:00
when {
! matcher.find() -> null
else -> {
val buttonLength = matcher.end()
val keyword = parseSearchPrev(previous)
2018-08-24 02:26:45 +02:00
when {
keyword?.isEmpty() != false -> null
else -> Node(
pos - (keyword.length + 1)
2018-08-24 02:26:45 +02:00
, buttonLength + (keyword.length + 1)
, arrayOf(keyword)
) {
val text = data[0]
closePreviousBlock()
val kw_start = sb.length // キーワードの開始位置
appendText(text)
appendText(" ")
start = sb.length // 検索リンクの開始位置
appendLink(
context.getString(R.string.search),
"https://www.google.co.jp/search?q=${text.encodePercent()}"
)
sb.setSpan(
RelativeSizeSpan(1.2f),
kw_start,
sb.length,
Spannable.SPAN_EXCLUSIVE_EXCLUSIVE
)
appendText("\n")
}
2018-08-24 02:26:45 +02:00
}
}
}
}
// [title] 【title】 直後に改行が必要
val titleParser = simpleParser(
Pattern.compile("""^[【\[](.+?)[】\]]\n""")
) {
closePreviousBlock()
appendText(trimBlock(data[0]))
setSpan(AlignmentSpan.Standard(Layout.Alignment.ALIGN_CENTER))
setSpan(BackgroundColorSpan(0x20808080))
setSpan(RelativeSizeSpan(1.5f))
appendText("\n")
}
2018-08-24 02:26:45 +02:00
// Link
2018-08-24 02:26:45 +02:00
val reLink = Pattern.compile(
"""^\??\[([^\[\]]+?)]\((https?://[\w/:%#@${'$'}&?!()\[\]~.=+\-]+?)\)"""
)
val linkParser : ParseEnv.() -> Node? = {
val matcher = reLink.matcher(remain)
when {
2018-08-24 02:26:45 +02:00
! matcher.find() -> null
else -> Node(
pos
, matcher.end()
, arrayOf(
matcher.group(1) // title
, matcher.group(2) // url
, remain[0].toString() // silent なら "?" になる
)
) {
val title = data[0]
val url = data[1]
// val silent = data?.get(2)
// silentはプレビュー表示を抑制するが、Subwayにはもともとないので関係なかった
if(url.isNotEmpty()) {
appendLink(title, url)
}
}
}
2018-08-24 02:26:45 +02:00
}
// [ はいろんな要素で使われる
2018-08-24 02:26:45 +02:00
addParser("[", titleParser, searchParser, linkParser)
// その他の文字でも判定する
2018-08-24 02:26:45 +02:00
addParser("", titleParser)
addParser("検Ss", searchParser)
addParser("?", linkParser)
// メンション @username @username@host
2018-08-24 02:26:45 +02:00
val reMention = Pattern.compile(
"""^@([a-z0-9_]+)(?:@([a-z0-9.\-]+[a-z0-9]))?"""
, Pattern.CASE_INSENSITIVE
)
addParser("@", {
val matcher = reMention.matcher(remain)
when {
2018-08-24 02:26:45 +02:00
! matcher.find() -> null
else -> Node(
pos
, matcher.end()
2018-08-24 18:24:11 +02:00
, arrayOf(matcher.group(1), matcher.group(2)?:"") // username, host
) {
val username = data[0]
val host = data[1]
val linkHelper = linkHelper
if(linkHelper == null) {
appendText(
when {
host.isEmpty() -> "@$username"
else -> "@$username@$host"
}
)
} else {
val shortAcct = when {
host.isEmpty()
|| host.equals(linkHelper.host, ignoreCase = true) ->
username
else ->
"$username@$host"
}
val userHost = when {
host.isEmpty() -> linkHelper.host
else -> host
}
val userUrl = "https://$userHost/@$username"
var mentions = sb.mentions
if(mentions == null) {
mentions = ArrayList()
sb.mentions = mentions
}
if(mentions.find { it.acct == shortAcct } == null) {
mentions.add(
TootMention(
EntityIdLong(- 1L)
, userUrl
, shortAcct
, username
)
)
}
appendLink(
when {
Pref.bpMentionFullAcct(App1.pref) -> "@$username@$userHost"
else -> "@$shortAcct"
}
, userUrl
)
}
}
}
2018-08-24 02:26:45 +02:00
})
// Hashtag
2018-08-24 02:26:45 +02:00
val reHashtag = Pattern.compile("""^#([^\s]+)""")
addParser("#"
, {
val matcher = reHashtag.matcher(remain)
2018-08-24 02:26:45 +02:00
when {
! matcher.find() -> null
else -> when {
// 先頭以外では直前に空白が必要らしい
pos > 0
&& ! CharacterGroup.isWhitespace(text[pos - 1].toInt()) ->
2018-08-24 02:26:45 +02:00
null
else -> Node(
pos
2018-08-24 02:26:45 +02:00
, matcher.end()
, arrayOf(matcher.group(1)) // 先頭の#を含まない
) {
val linkHelper = linkHelper
val tag = data[0]
if(tag.isNotEmpty() && linkHelper != null) {
appendLink(
"#$tag",
"https://${linkHelper.host}/tags/" + tag.encodePercent()
)
}
}
2018-08-24 02:26:45 +02:00
}
}
}
2018-08-24 02:26:45 +02:00
)
// code (ブロック、インライン)
2018-08-24 02:26:45 +02:00
addParser(
"`"
, simpleParser(
Pattern.compile("""^```(.+?)```""", Pattern.DOTALL)
) {
closePreviousBlock()
appendText(MisskeySyntaxHighlighter.parse(trimBlock(data[0])))
setSpan(BackgroundColorSpan(0x40808080))
setSpan(RelativeSizeSpan(0.7f))
setSpan(CalligraphyTypefaceSpan(Typeface.MONOSPACE))
appendText("\n")
}
2018-08-24 02:26:45 +02:00
, simpleParser(
// インラインコードは内部にとある文字を含むと認識されない。理由は顔文字と衝突するからだとか
Pattern.compile("""^`([^`´\x0d\x0a]+)`""")
) {
appendText(MisskeySyntaxHighlighter.parse(data[0]))
setSpan(BackgroundColorSpan(0x40808080))
setSpan(CalligraphyTypefaceSpan(Typeface.MONOSPACE))
}
2018-08-24 02:26:45 +02:00
)
}
// ブロック要素は始端と終端の空行を除去したい
2018-08-22 03:05:54 +02:00
private val reStartEmptyLines = """\A(?:[  ]*?[\x0d\x0a]+)+""".toRegex()
private val reEndEmptyLines = """[\s\x0d\x0a]+\z""".toRegex()
private fun trimBlock(s : String) =
s
.replace(reStartEmptyLines, "")
.replace(reEndEmptyLines, "")
// このファイルのエントリーポイント
2018-08-24 02:26:45 +02:00
fun decodeMarkdown(options : DecodeOptions, src : String?) =
SpannableStringBuilderEx().apply {
try {
val env = SpanOutputEnv(options, this)
if(src != null) ParseEnv(src).parse { node ->
env.nodeSource = src.substring(node.start, node.start + node.length)
env.start = length
env.data = node.data
val decoder = node.decoder
env.decoder()
}
// 末尾の空白を取り除く
2018-08-24 02:26:45 +02:00
val end = length
var pos = end
while(pos > 0 && HTMLDecoder.isWhitespaceOrLineFeed(get(pos - 1).toInt())) -- pos
if(pos < end) delete(pos, end)
} catch(ex : Throwable) {
log.trace(ex)
log.e(ex, "decodeMarkdown failed")
}
}
}