diff --git a/changelog.d/2133.feature b/changelog.d/2133.feature new file mode 100644 index 0000000000..5649ca4cc6 --- /dev/null +++ b/changelog.d/2133.feature @@ -0,0 +1 @@ +Add labs support for rendering LaTeX maths (MSC2191) diff --git a/dependencies.gradle b/dependencies.gradle index 4a076a23bd..b975abba0b 100644 --- a/dependencies.gradle +++ b/dependencies.gradle @@ -14,7 +14,7 @@ def kotlinCoroutines = "1.5.2" def dagger = "2.40.5" def retrofit = "2.9.0" def arrow = "0.8.2" -def markwon = "4.6.2" +def markwon = "4.3.1" def moshi = "1.12.0" def lifecycle = "2.4.0" def flowBinding = "1.2.0" @@ -95,6 +95,8 @@ ext.libs = [ ], 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" ], airbnb : [ diff --git a/matrix-sdk-android/src/main/java/org/commonmark/ext/maths/DisplayMaths.kt b/matrix-sdk-android/src/main/java/org/commonmark/ext/maths/DisplayMaths.kt new file mode 100644 index 0000000000..a69649640f --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/commonmark/ext/maths/DisplayMaths.kt @@ -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 + } +} diff --git a/matrix-sdk-android/src/main/java/org/commonmark/ext/maths/InlineMaths.kt b/matrix-sdk-android/src/main/java/org/commonmark/ext/maths/InlineMaths.kt new file mode 100644 index 0000000000..799626045d --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/commonmark/ext/maths/InlineMaths.kt @@ -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 -> "\\)" + } + } +} diff --git a/matrix-sdk-android/src/main/java/org/commonmark/ext/maths/MathsExtension.kt b/matrix-sdk-android/src/main/java/org/commonmark/ext/maths/MathsExtension.kt new file mode 100644 index 0000000000..18c0fc4284 --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/commonmark/ext/maths/MathsExtension.kt @@ -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() + } + } +} diff --git a/matrix-sdk-android/src/main/java/org/commonmark/ext/maths/internal/DollarMathsDelimiterProcessor.kt b/matrix-sdk-android/src/main/java/org/commonmark/ext/maths/internal/DollarMathsDelimiterProcessor.kt new file mode 100644 index 0000000000..55b27a21bc --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/commonmark/ext/maths/internal/DollarMathsDelimiterProcessor.kt @@ -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) + } +} diff --git a/matrix-sdk-android/src/main/java/org/commonmark/ext/maths/internal/MathsHtmlNodeRenderer.kt b/matrix-sdk-android/src/main/java/org/commonmark/ext/maths/internal/MathsHtmlNodeRenderer.kt new file mode 100644 index 0000000000..94652ed7ad --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/commonmark/ext/maths/internal/MathsHtmlNodeRenderer.kt @@ -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") + } +} diff --git a/matrix-sdk-android/src/main/java/org/commonmark/ext/maths/internal/MathsNodeRenderer.kt b/matrix-sdk-android/src/main/java/org/commonmark/ext/maths/internal/MathsNodeRenderer.kt new file mode 100644 index 0000000000..d5ce47abeb --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/commonmark/ext/maths/internal/MathsNodeRenderer.kt @@ -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> { + val types: MutableSet> = HashSet() + types.add(InlineMaths::class.java) + types.add(DisplayMaths::class.java) + return types + } +} diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/RoomModule.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/RoomModule.kt index dbd0ae6f06..105c8ad03e 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/RoomModule.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/RoomModule.kt @@ -19,6 +19,8 @@ package org.matrix.android.sdk.internal.session.room import dagger.Binds import dagger.Module import dagger.Provides +import org.commonmark.Extension +import org.commonmark.ext.maths.MathsExtension import org.commonmark.parser.Parser import org.commonmark.renderer.html.HtmlRenderer import org.matrix.android.sdk.api.session.file.FileService @@ -104,6 +106,7 @@ internal abstract class RoomModule { @Module companion object { + private val extensions : List = listOf(MathsExtension.create()) @Provides @JvmStatic @SessionScope @@ -121,7 +124,7 @@ internal abstract class RoomModule { @Provides @JvmStatic fun providesParser(): Parser { - return Parser.builder().build() + return Parser.builder().extensions(extensions).build() } @Provides @@ -129,6 +132,7 @@ internal abstract class RoomModule { fun providesHtmlRenderer(): HtmlRenderer { return HtmlRenderer .builder() + .extensions(extensions) .softbreak("
") .build() } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/send/MarkdownParser.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/send/MarkdownParser.kt index c99d482300..1ac95154f8 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/send/MarkdownParser.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/send/MarkdownParser.kt @@ -32,7 +32,7 @@ internal class MarkdownParser @Inject constructor( private val textPillsUtils: TextPillsUtils ) { - private val mdSpecialChars = "[`_\\-*>.\\[\\]#~]".toRegex() + private val mdSpecialChars = "[`_\\-*>.\\[\\]#~$]".toRegex() fun parse(text: CharSequence): TextContent { val source = textPillsUtils.processSpecialSpansToMarkdown(text) ?: text.toString() diff --git a/vector/build.gradle b/vector/build.gradle index d7ed497bee..f952f76775 100644 --- a/vector/build.gradle +++ b/vector/build.gradle @@ -389,6 +389,8 @@ dependencies { implementation libs.google.material implementation 'me.gujun.android:span:1.7' implementation libs.markwon.core + implementation libs.markwon.extLatex + implementation libs.markwon.inlineParser implementation libs.markwon.html implementation 'com.googlecode.htmlcompressor:htmlcompressor:1.5.2' implementation 'me.saket:better-link-movement-method:2.2.0' diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/MessageItemFactory.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/MessageItemFactory.kt index 28b2d98909..0e45cb8e2d 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/MessageItemFactory.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/MessageItemFactory.kt @@ -511,6 +511,7 @@ class MessageItemFactory @Inject constructor( } .useBigFont(linkifiedBody.length <= MAX_NUMBER_OF_EMOJI_FOR_BIG_FONT * 2 && containsOnlyEmojis(linkifiedBody.toString())) .bindingOptions(bindingOptions) + .markwonPlugins(htmlRenderer.get().plugins) .searchForPills(isFormatted) .previewUrlRetriever(callback?.getPreviewUrlRetriever()) .imageContentRenderer(imageContentRenderer) diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/MessageTextItem.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/MessageTextItem.kt index 276008f09e..b330fbd023 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/MessageTextItem.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/MessageTextItem.kt @@ -16,6 +16,7 @@ package im.vector.app.features.home.room.detail.timeline.item +import android.text.Spanned import android.text.method.MovementMethod import androidx.appcompat.widget.AppCompatTextView 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.PreviewUrlView import im.vector.app.features.media.ImageContentRenderer +import io.noties.markwon.MarkwonPlugin import org.matrix.android.sdk.api.extensions.orFalse @EpoxyModelClass(layout = R.layout.item_timeline_event_base) @@ -62,6 +64,9 @@ abstract class MessageTextItem : AbsMessageItem() { @EpoxyAttribute(EpoxyAttribute.Option.DoNotHash) var movementMethod: MovementMethod? = null + @EpoxyAttribute(EpoxyAttribute.Option.DoNotHash) + var markwonPlugins: (List)? = null + private val previewUrlViewUpdater = PreviewUrlViewUpdater() override fun bind(holder: Holder) { @@ -95,6 +100,7 @@ abstract class MessageTextItem : AbsMessageItem() { } else { null } + markwonPlugins?.forEach { plugin -> plugin.beforeSetText(holder.messageView, message as Spanned) } super.bind(holder) holder.messageView.movementMethod = movementMethod renderSendState(holder.messageView, holder.messageView) @@ -110,6 +116,7 @@ abstract class MessageTextItem : AbsMessageItem() { message } } + markwonPlugins?.forEach { plugin -> plugin.afterSetText(holder.messageView) } } override fun unbind(holder: Holder) { diff --git a/vector/src/main/java/im/vector/app/features/html/EventHtmlRenderer.kt b/vector/src/main/java/im/vector/app/features/html/EventHtmlRenderer.kt index d3b3be6a3e..2d832b60cc 100644 --- a/vector/src/main/java/im/vector/app/features/html/EventHtmlRenderer.kt +++ b/vector/src/main/java/im/vector/app/features/html/EventHtmlRenderer.kt @@ -20,8 +20,15 @@ import android.content.Context import android.text.Spannable import androidx.core.text.toSpannable 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.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.inlineparser.MarkwonInlineParserPlugin import org.commonmark.node.Node import timber.log.Timber import javax.inject.Inject @@ -29,15 +36,39 @@ import javax.inject.Singleton @Singleton class EventHtmlRenderer @Inject constructor(htmlConfigure: MatrixHtmlPluginConfigure, - context: Context) { + context: Context, + private val vectorPreferences: VectorPreferences) { interface PostProcessor { fun afterRender(renderedText: Spannable) } - private val markwon = Markwon.builder(context) + private val builder = Markwon.builder(context) .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(""".*?""")) { + matchResult -> "$$" + matchResult.groupValues[1] + "$$" + } + .replace(Regex(""".*?""")) { + 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 = markwon.plugins fun parse(text: String): Node { return markwon.parse(text) diff --git a/vector/src/main/java/im/vector/app/features/settings/VectorPreferences.kt b/vector/src/main/java/im/vector/app/features/settings/VectorPreferences.kt index 3436c20ce3..3f423696ae 100755 --- a/vector/src/main/java/im/vector/app/features/settings/VectorPreferences.kt +++ b/vector/src/main/java/im/vector/app/features/settings/VectorPreferences.kt @@ -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_ENABLE_CHAT_EFFECTS = "SETTINGS_ENABLE_CHAT_EFFECTS" 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 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) } + fun latexMathsIsEnabled(): Boolean { + return defaultPrefs.getBoolean(SETTINGS_LABS_ENABLE_LATEX_MATHS, false) + } + fun failFast(): Boolean { return BuildConfig.DEBUG || (developerMode() && defaultPrefs.getBoolean(SETTINGS_DEVELOPER_MODE_FAIL_FAST_PREFERENCE_KEY, false)) } diff --git a/vector/src/main/res/values/strings.xml b/vector/src/main/res/values/strings.xml index 34ac5fcddc..7580c1da63 100644 --- a/vector/src/main/res/values/strings.xml +++ b/vector/src/main/res/values/strings.xml @@ -3661,6 +3661,8 @@ %s in Settings to receive invites directly in Element. + Enable LaTeX mathematics + Create Poll Poll question or topic diff --git a/vector/src/main/res/xml/vector_settings_labs.xml b/vector/src/main/res/xml/vector_settings_labs.xml index 8e92b65e73..2e8ed08bf4 100644 --- a/vector/src/main/res/xml/vector_settings_labs.xml +++ b/vector/src/main/res/xml/vector_settings_labs.xml @@ -49,6 +49,11 @@ android:key="SETTINGS_LABS_USE_RESTRICTED_JOIN_RULE" android:title="@string/labs_use_restricted_join_rule" android:summary="@string/labs_use_restricted_join_rule_desc"/> + +