Merge pull request #2133 from NickHu/maths

Maths support (MSC2191)
This commit is contained in:
Benoit Marty 2021-12-31 16:44:55 +01:00 committed by GitHub
commit 4a1c92421b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
17 changed files with 291 additions and 6 deletions

1
changelog.d/2133.feature Normal file
View file

@ -0,0 +1 @@
Add labs support for rendering LaTeX maths (MSC2191)

View file

@ -14,7 +14,7 @@ def kotlinCoroutines = "1.5.2"
def dagger = "2.40.5" def dagger = "2.40.5"
def retrofit = "2.9.0" def retrofit = "2.9.0"
def arrow = "0.8.2" def arrow = "0.8.2"
def markwon = "4.6.2" def markwon = "4.3.1"
def moshi = "1.12.0" def moshi = "1.12.0"
def lifecycle = "2.4.0" def lifecycle = "2.4.0"
def flowBinding = "1.2.0" def flowBinding = "1.2.0"
@ -95,6 +95,8 @@ ext.libs = [
], ],
markwon : [ markwon : [
'core' : "io.noties.markwon:core:$markwon", 'core' : "io.noties.markwon:core:$markwon",
'extLatex' : "io.noties.markwon:ext-latex:$markwon",
'inlineParser' : "io.noties.markwon:inline-parser:$markwon",
'html' : "io.noties.markwon:html:$markwon" 'html' : "io.noties.markwon:html:$markwon"
], ],
airbnb : [ airbnb : [

View file

@ -0,0 +1,24 @@
/*
* Copyright (c) 2021 The Matrix.org Foundation C.I.C.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.commonmark.ext.maths
import org.commonmark.node.CustomBlock
class DisplayMaths(private val delimiter: DisplayDelimiter) : CustomBlock() {
enum class DisplayDelimiter {
DOUBLE_DOLLAR, SQUARE_BRACKET_ESCAPED
}
}

View file

@ -0,0 +1,39 @@
/*
* Copyright (c) 2021 The Matrix.org Foundation C.I.C.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.commonmark.ext.maths
import org.commonmark.node.CustomNode
import org.commonmark.node.Delimited
class InlineMaths(private val delimiter: InlineDelimiter) : CustomNode(), Delimited {
enum class InlineDelimiter {
SINGLE_DOLLAR, ROUND_BRACKET_ESCAPED
}
override fun getOpeningDelimiter(): String {
return when (delimiter) {
InlineDelimiter.SINGLE_DOLLAR -> "$"
InlineDelimiter.ROUND_BRACKET_ESCAPED -> "\\("
}
}
override fun getClosingDelimiter(): String {
return when (delimiter) {
InlineDelimiter.SINGLE_DOLLAR -> "$"
InlineDelimiter.ROUND_BRACKET_ESCAPED -> "\\)"
}
}
}

View file

@ -0,0 +1,38 @@
/*
* Copyright (c) 2021 The Matrix.org Foundation C.I.C.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.commonmark.ext.maths
import org.commonmark.Extension
import org.commonmark.ext.maths.internal.DollarMathsDelimiterProcessor
import org.commonmark.ext.maths.internal.MathsHtmlNodeRenderer
import org.commonmark.parser.Parser
import org.commonmark.renderer.html.HtmlRenderer
class MathsExtension private constructor() : Parser.ParserExtension, HtmlRenderer.HtmlRendererExtension {
override fun extend(parserBuilder: Parser.Builder) {
parserBuilder.customDelimiterProcessor(DollarMathsDelimiterProcessor())
}
override fun extend(rendererBuilder: HtmlRenderer.Builder) {
rendererBuilder.nodeRendererFactory { context -> MathsHtmlNodeRenderer(context) }
}
companion object {
fun create(): Extension {
return MathsExtension()
}
}
}

View file

@ -0,0 +1,53 @@
/*
* Copyright (c) 2021 The Matrix.org Foundation C.I.C.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.commonmark.ext.maths.internal
import org.commonmark.ext.maths.DisplayMaths
import org.commonmark.ext.maths.InlineMaths
import org.commonmark.node.Text
import org.commonmark.parser.delimiter.DelimiterProcessor
import org.commonmark.parser.delimiter.DelimiterRun
class DollarMathsDelimiterProcessor : DelimiterProcessor {
override fun getOpeningCharacter(): Char {
return '$'
}
override fun getClosingCharacter(): Char {
return '$'
}
override fun getMinLength(): Int {
return 1
}
override fun getDelimiterUse(opener: DelimiterRun, closer: DelimiterRun): Int {
return if (opener.length() == 1 && closer.length() == 1) 1 // inline
else if (opener.length() == 2 && closer.length() == 2) 2 // display
else 0
}
override fun process(opener: Text, closer: Text, delimiterUse: Int) {
val maths = if (delimiterUse == 1) InlineMaths(InlineMaths.InlineDelimiter.SINGLE_DOLLAR) else DisplayMaths(DisplayMaths.DisplayDelimiter.DOUBLE_DOLLAR)
var tmp = opener.next
while (tmp != null && tmp !== closer) {
val next = tmp.next
maths.appendChild(tmp)
tmp = next
}
opener.insertAfter(maths)
}
}

View file

@ -0,0 +1,39 @@
/*
* Copyright (c) 2021 The Matrix.org Foundation C.I.C.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.commonmark.ext.maths.internal
import org.commonmark.ext.maths.DisplayMaths
import org.commonmark.node.Node
import org.commonmark.node.Text
import org.commonmark.renderer.html.HtmlNodeRendererContext
import org.commonmark.renderer.html.HtmlWriter
import java.util.Collections
class MathsHtmlNodeRenderer(private val context: HtmlNodeRendererContext) : MathsNodeRenderer() {
private val html: HtmlWriter = context.writer
override fun render(node: Node) {
val display = node.javaClass == DisplayMaths::class.java
val contents = node.firstChild // should be the only child
val latex = (contents as Text).literal
val attributes = context.extendAttributes(node, if (display) "div" else "span", Collections.singletonMap("data-mx-maths",
latex))
html.tag(if (display) "div" else "span", attributes)
html.tag("code")
context.render(contents)
html.tag("/code")
html.tag(if (display) "/div" else "/span")
}
}

View file

@ -0,0 +1,32 @@
/*
* Copyright (c) 2021 The Matrix.org Foundation C.I.C.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.commonmark.ext.maths.internal
import org.commonmark.ext.maths.InlineMaths
import org.commonmark.ext.maths.DisplayMaths
import org.commonmark.ext.maths.internal.MathsNodeRenderer
import org.commonmark.node.Node
import org.commonmark.renderer.NodeRenderer
import java.util.HashSet
abstract class MathsNodeRenderer : NodeRenderer {
override fun getNodeTypes(): Set<Class<out Node>> {
val types: MutableSet<Class<out Node>> = HashSet()
types.add(InlineMaths::class.java)
types.add(DisplayMaths::class.java)
return types
}
}

View file

@ -19,6 +19,8 @@ package org.matrix.android.sdk.internal.session.room
import dagger.Binds import dagger.Binds
import dagger.Module import dagger.Module
import dagger.Provides import dagger.Provides
import org.commonmark.Extension
import org.commonmark.ext.maths.MathsExtension
import org.commonmark.parser.Parser import org.commonmark.parser.Parser
import org.commonmark.renderer.html.HtmlRenderer import org.commonmark.renderer.html.HtmlRenderer
import org.matrix.android.sdk.api.session.file.FileService import org.matrix.android.sdk.api.session.file.FileService
@ -104,6 +106,7 @@ internal abstract class RoomModule {
@Module @Module
companion object { companion object {
private val extensions : List<Extension> = listOf(MathsExtension.create())
@Provides @Provides
@JvmStatic @JvmStatic
@SessionScope @SessionScope
@ -121,7 +124,7 @@ internal abstract class RoomModule {
@Provides @Provides
@JvmStatic @JvmStatic
fun providesParser(): Parser { fun providesParser(): Parser {
return Parser.builder().build() return Parser.builder().extensions(extensions).build()
} }
@Provides @Provides
@ -129,6 +132,7 @@ internal abstract class RoomModule {
fun providesHtmlRenderer(): HtmlRenderer { fun providesHtmlRenderer(): HtmlRenderer {
return HtmlRenderer return HtmlRenderer
.builder() .builder()
.extensions(extensions)
.softbreak("<br />") .softbreak("<br />")
.build() .build()
} }

View file

@ -32,7 +32,7 @@ internal class MarkdownParser @Inject constructor(
private val textPillsUtils: TextPillsUtils private val textPillsUtils: TextPillsUtils
) { ) {
private val mdSpecialChars = "[`_\\-*>.\\[\\]#~]".toRegex() private val mdSpecialChars = "[`_\\-*>.\\[\\]#~$]".toRegex()
fun parse(text: CharSequence): TextContent { fun parse(text: CharSequence): TextContent {
val source = textPillsUtils.processSpecialSpansToMarkdown(text) ?: text.toString() val source = textPillsUtils.processSpecialSpansToMarkdown(text) ?: text.toString()

View file

@ -389,6 +389,8 @@ dependencies {
implementation libs.google.material implementation libs.google.material
implementation 'me.gujun.android:span:1.7' implementation 'me.gujun.android:span:1.7'
implementation libs.markwon.core implementation libs.markwon.core
implementation libs.markwon.extLatex
implementation libs.markwon.inlineParser
implementation libs.markwon.html implementation libs.markwon.html
implementation 'com.googlecode.htmlcompressor:htmlcompressor:1.5.2' implementation 'com.googlecode.htmlcompressor:htmlcompressor:1.5.2'
implementation 'me.saket:better-link-movement-method:2.2.0' implementation 'me.saket:better-link-movement-method:2.2.0'

View file

@ -511,6 +511,7 @@ class MessageItemFactory @Inject constructor(
} }
.useBigFont(linkifiedBody.length <= MAX_NUMBER_OF_EMOJI_FOR_BIG_FONT * 2 && containsOnlyEmojis(linkifiedBody.toString())) .useBigFont(linkifiedBody.length <= MAX_NUMBER_OF_EMOJI_FOR_BIG_FONT * 2 && containsOnlyEmojis(linkifiedBody.toString()))
.bindingOptions(bindingOptions) .bindingOptions(bindingOptions)
.markwonPlugins(htmlRenderer.get().plugins)
.searchForPills(isFormatted) .searchForPills(isFormatted)
.previewUrlRetriever(callback?.getPreviewUrlRetriever()) .previewUrlRetriever(callback?.getPreviewUrlRetriever())
.imageContentRenderer(imageContentRenderer) .imageContentRenderer(imageContentRenderer)

View file

@ -16,6 +16,7 @@
package im.vector.app.features.home.room.detail.timeline.item package im.vector.app.features.home.room.detail.timeline.item
import android.text.Spanned
import android.text.method.MovementMethod import android.text.method.MovementMethod
import androidx.appcompat.widget.AppCompatTextView import androidx.appcompat.widget.AppCompatTextView
import androidx.core.text.PrecomputedTextCompat import androidx.core.text.PrecomputedTextCompat
@ -33,6 +34,7 @@ import im.vector.app.features.home.room.detail.timeline.url.PreviewUrlRetriever
import im.vector.app.features.home.room.detail.timeline.url.PreviewUrlUiState import im.vector.app.features.home.room.detail.timeline.url.PreviewUrlUiState
import im.vector.app.features.home.room.detail.timeline.url.PreviewUrlView import im.vector.app.features.home.room.detail.timeline.url.PreviewUrlView
import im.vector.app.features.media.ImageContentRenderer import im.vector.app.features.media.ImageContentRenderer
import io.noties.markwon.MarkwonPlugin
import org.matrix.android.sdk.api.extensions.orFalse import org.matrix.android.sdk.api.extensions.orFalse
@EpoxyModelClass(layout = R.layout.item_timeline_event_base) @EpoxyModelClass(layout = R.layout.item_timeline_event_base)
@ -62,6 +64,9 @@ abstract class MessageTextItem : AbsMessageItem<MessageTextItem.Holder>() {
@EpoxyAttribute(EpoxyAttribute.Option.DoNotHash) @EpoxyAttribute(EpoxyAttribute.Option.DoNotHash)
var movementMethod: MovementMethod? = null var movementMethod: MovementMethod? = null
@EpoxyAttribute(EpoxyAttribute.Option.DoNotHash)
var markwonPlugins: (List<MarkwonPlugin>)? = null
private val previewUrlViewUpdater = PreviewUrlViewUpdater() private val previewUrlViewUpdater = PreviewUrlViewUpdater()
override fun bind(holder: Holder) { override fun bind(holder: Holder) {
@ -95,6 +100,7 @@ abstract class MessageTextItem : AbsMessageItem<MessageTextItem.Holder>() {
} else { } else {
null null
} }
markwonPlugins?.forEach { plugin -> plugin.beforeSetText(holder.messageView, message as Spanned) }
super.bind(holder) super.bind(holder)
holder.messageView.movementMethod = movementMethod holder.messageView.movementMethod = movementMethod
renderSendState(holder.messageView, holder.messageView) renderSendState(holder.messageView, holder.messageView)
@ -110,6 +116,7 @@ abstract class MessageTextItem : AbsMessageItem<MessageTextItem.Holder>() {
message message
} }
} }
markwonPlugins?.forEach { plugin -> plugin.afterSetText(holder.messageView) }
} }
override fun unbind(holder: Holder) { override fun unbind(holder: Holder) {

View file

@ -20,8 +20,15 @@ import android.content.Context
import android.text.Spannable import android.text.Spannable
import androidx.core.text.toSpannable import androidx.core.text.toSpannable
import im.vector.app.core.resources.ColorProvider import im.vector.app.core.resources.ColorProvider
import im.vector.app.features.settings.VectorPreferences
import io.noties.markwon.AbstractMarkwonPlugin
import io.noties.markwon.Markwon import io.noties.markwon.Markwon
import io.noties.markwon.MarkwonPlugin
import io.noties.markwon.PrecomputedFutureTextSetterCompat
import io.noties.markwon.ext.latex.JLatexMathPlugin
import io.noties.markwon.ext.latex.JLatexMathTheme
import io.noties.markwon.html.HtmlPlugin import io.noties.markwon.html.HtmlPlugin
import io.noties.markwon.inlineparser.MarkwonInlineParserPlugin
import org.commonmark.node.Node import org.commonmark.node.Node
import timber.log.Timber import timber.log.Timber
import javax.inject.Inject import javax.inject.Inject
@ -29,15 +36,39 @@ import javax.inject.Singleton
@Singleton @Singleton
class EventHtmlRenderer @Inject constructor(htmlConfigure: MatrixHtmlPluginConfigure, class EventHtmlRenderer @Inject constructor(htmlConfigure: MatrixHtmlPluginConfigure,
context: Context) { context: Context,
private val vectorPreferences: VectorPreferences) {
interface PostProcessor { interface PostProcessor {
fun afterRender(renderedText: Spannable) fun afterRender(renderedText: Spannable)
} }
private val markwon = Markwon.builder(context) private val builder = Markwon.builder(context)
.usePlugin(HtmlPlugin.create(htmlConfigure)) .usePlugin(HtmlPlugin.create(htmlConfigure))
.build()
private val markwon = if (vectorPreferences.latexMathsIsEnabled()) {
builder
.usePlugin(object : AbstractMarkwonPlugin() { // Markwon expects maths to be in a specific format: https://noties.io/Markwon/docs/v4/ext-latex
override fun processMarkdown(markdown: String): String {
return markdown
.replace(Regex("""<span\s+data-mx-maths="([^"]*)">.*?</span>""")) {
matchResult -> "$$" + matchResult.groupValues[1] + "$$"
}
.replace(Regex("""<div\s+data-mx-maths="([^"]*)">.*?</div>""")) {
matchResult -> "\n$$\n" + matchResult.groupValues[1] + "\n$$\n"
}
}
})
.usePlugin(MarkwonInlineParserPlugin.create())
.usePlugin(JLatexMathPlugin.create(44F) { builder ->
builder.inlinesEnabled(true)
builder.theme().inlinePadding(JLatexMathTheme.Padding.symmetric(24, 8))
})
} else {
builder
}.textSetter(PrecomputedFutureTextSetterCompat.create()).build()
val plugins: List<MarkwonPlugin> = markwon.plugins
fun parse(text: String): Node { fun parse(text: String): Node {
return markwon.parse(text) return markwon.parse(text)

View file

@ -97,6 +97,7 @@ class VectorPreferences @Inject constructor(private val context: Context) {
private const val SETTINGS_SEND_MESSAGE_WITH_ENTER = "SETTINGS_SEND_MESSAGE_WITH_ENTER" private const val SETTINGS_SEND_MESSAGE_WITH_ENTER = "SETTINGS_SEND_MESSAGE_WITH_ENTER"
private const val SETTINGS_ENABLE_CHAT_EFFECTS = "SETTINGS_ENABLE_CHAT_EFFECTS" private const val SETTINGS_ENABLE_CHAT_EFFECTS = "SETTINGS_ENABLE_CHAT_EFFECTS"
private const val SETTINGS_SHOW_EMOJI_KEYBOARD = "SETTINGS_SHOW_EMOJI_KEYBOARD" private const val SETTINGS_SHOW_EMOJI_KEYBOARD = "SETTINGS_SHOW_EMOJI_KEYBOARD"
private const val SETTINGS_LABS_ENABLE_LATEX_MATHS = "SETTINGS_LABS_ENABLE_LATEX_MATHS"
// Room directory // Room directory
private const val SETTINGS_ROOM_DIRECTORY_SHOW_ALL_PUBLIC_ROOMS = "SETTINGS_ROOM_DIRECTORY_SHOW_ALL_PUBLIC_ROOMS" private const val SETTINGS_ROOM_DIRECTORY_SHOW_ALL_PUBLIC_ROOMS = "SETTINGS_ROOM_DIRECTORY_SHOW_ALL_PUBLIC_ROOMS"
@ -334,6 +335,10 @@ class VectorPreferences @Inject constructor(private val context: Context) {
return defaultPrefs.getBoolean(SETTINGS_LABS_UNREAD_NOTIFICATIONS_AS_TAB, false) return defaultPrefs.getBoolean(SETTINGS_LABS_UNREAD_NOTIFICATIONS_AS_TAB, false)
} }
fun latexMathsIsEnabled(): Boolean {
return defaultPrefs.getBoolean(SETTINGS_LABS_ENABLE_LATEX_MATHS, false)
}
fun failFast(): Boolean { fun failFast(): Boolean {
return BuildConfig.DEBUG || (developerMode() && defaultPrefs.getBoolean(SETTINGS_DEVELOPER_MODE_FAIL_FAST_PREFERENCE_KEY, false)) return BuildConfig.DEBUG || (developerMode() && defaultPrefs.getBoolean(SETTINGS_DEVELOPER_MODE_FAIL_FAST_PREFERENCE_KEY, false))
} }

View file

@ -3661,6 +3661,8 @@
<!-- %s will be replaced by the value of link_this_email_settings_link and styled as a link --> <!-- %s will be replaced by the value of link_this_email_settings_link and styled as a link -->
<string name="link_this_email_with_your_account">%s in Settings to receive invites directly in Element.</string> <string name="link_this_email_with_your_account">%s in Settings to receive invites directly in Element.</string>
<string name="labs_enable_latex_maths">Enable LaTeX mathematics</string>
<!-- Poll --> <!-- Poll -->
<string name="create_poll_title">Create Poll</string> <string name="create_poll_title">Create Poll</string>
<string name="create_poll_question_title">Poll question or topic</string> <string name="create_poll_question_title">Poll question or topic</string>

View file

@ -49,6 +49,11 @@
android:key="SETTINGS_LABS_USE_RESTRICTED_JOIN_RULE" android:key="SETTINGS_LABS_USE_RESTRICTED_JOIN_RULE"
android:title="@string/labs_use_restricted_join_rule" android:title="@string/labs_use_restricted_join_rule"
android:summary="@string/labs_use_restricted_join_rule_desc"/> android:summary="@string/labs_use_restricted_join_rule_desc"/>
<im.vector.app.core.preference.VectorSwitchPreference
android:defaultValue="false"
android:key="SETTINGS_LABS_ENABLE_LATEX_MATHS"
android:title="@string/labs_enable_latex_maths"/>
<!--</im.vector.app.core.preference.VectorPreferenceCategory>--> <!--</im.vector.app.core.preference.VectorPreferenceCategory>-->
<im.vector.app.core.preference.VectorSwitchPreference <im.vector.app.core.preference.VectorSwitchPreference