Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/4777.bugfix
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Fixes numbered lists always starting from 1
91 changes: 91 additions & 0 deletions vector/src/androidTest/java/im/vector/app/core/utils/TestSpan.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
/*
* Copyright (c) 2022 New Vector Ltd
*
* 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 im.vector.app.core.utils

import android.graphics.Canvas
import android.graphics.Paint
import android.text.Layout
import android.text.Spannable
import androidx.core.text.getSpans
import im.vector.app.features.html.HtmlCodeSpan
import io.mockk.justRun
import io.mockk.mockk
import io.mockk.slot
import io.mockk.verify
import io.noties.markwon.core.spans.EmphasisSpan
import io.noties.markwon.core.spans.OrderedListItemSpan
import io.noties.markwon.core.spans.StrongEmphasisSpan

fun Spannable.toTestSpan(): String {
var output = toString()
readSpansWithContent().forEach {
val tags = it.span.readTags()
val remappedContent = it.span.remapContent(source = this, originalContent = it.content)
output = output.replace(it.content, "${tags.open}$remappedContent${tags.close}")
}
return output
}

private fun Spannable.readSpansWithContent() = getSpans<Any>().map { span ->
val start = getSpanStart(span)
val end = getSpanEnd(span)
SpanWithContent(
content = substring(start, end),
span = span
)
}.reversed()

private fun Any.readTags(): SpanTags {
return when (this::class) {
OrderedListItemSpan::class -> SpanTags("[list item]", "[/list item]")
HtmlCodeSpan::class -> SpanTags("[code]", "[/code]")
StrongEmphasisSpan::class -> SpanTags("[bold]", "[/bold]")
EmphasisSpan::class -> SpanTags("[italic]", "[/italic]")
else -> throw IllegalArgumentException("Unknown ${this::class}")
}
}

private fun Any.remapContent(source: CharSequence, originalContent: String): String {
return when (this::class) {
OrderedListItemSpan::class -> {
val prefix = (this as OrderedListItemSpan).collectNumber(source)
"$prefix$originalContent"
}
else -> originalContent
}
}

private fun OrderedListItemSpan.collectNumber(text: CharSequence): String {
val fakeCanvas = mockk<Canvas>()
val fakeLayout = mockk<Layout>()
justRun { fakeCanvas.drawText(any(), any(), any(), any()) }
val paint = Paint()
drawLeadingMargin(fakeCanvas, paint, 0, 0, 0, 0, 0, text, 0, text.length - 1, true, fakeLayout)
val slot = slot<String>()
verify { fakeCanvas.drawText(capture(slot), any(), any(), any()) }
return slot.captured
}

private data class SpanTags(
val open: String,
val close: String,
)

private data class SpanWithContent(
val content: String,
val span: Any
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
/*
* Copyright (c) 2022 New Vector Ltd
*
* 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 im.vector.app.features.html

import androidx.core.text.toSpannable
import androidx.test.platform.app.InstrumentationRegistry
import im.vector.app.core.resources.ColorProvider
import im.vector.app.core.utils.toTestSpan
import im.vector.app.features.settings.VectorPreferences
import io.mockk.every
import io.mockk.mockk
import org.amshove.kluent.shouldBeEqualTo
import org.junit.Test
import org.junit.runner.RunWith
import org.junit.runners.JUnit4
import kotlin.text.Typography.nbsp

@RunWith(JUnit4::class)
class EventHtmlRendererTest {

private val context = InstrumentationRegistry.getInstrumentation().targetContext
private val fakeVectorPreferences = mockk<VectorPreferences>().also {
every { it.latexMathsIsEnabled() } returns false
}

private val renderer = EventHtmlRenderer(
MatrixHtmlPluginConfigure(ColorProvider(context), context.resources),
context,
fakeVectorPreferences
)

@Test
fun takesInitialListPositionIntoAccount() {
val result = """<ol start="5"><li>first entry<li></ol>""".renderAsTestSpan()

result shouldBeEqualTo "[list item]5.${nbsp}first entry[/list item]\n"
}

@Test
fun doesNotProcessMarkdownWithinCodeBlocks() {
val result = """<code>__italic__ **bold**</code>""".renderAsTestSpan()

result shouldBeEqualTo "[code]__italic__ **bold**[/code]"
}

@Test
fun doesNotProcessMarkdownBoldAndItalic() {
val result = """__italic__ **bold**""".renderAsTestSpan()

result shouldBeEqualTo "__italic__ **bold**"
}

@Test
fun processesHtmlWithinCodeBlocks() {
val result = """<code><i>italic</i> <b>bold</b></code>""".renderAsTestSpan()

result shouldBeEqualTo "[code][italic]italic[/italic] [bold]bold[/bold][/code]"
}

@Test
fun processesHtmlEntities() {
val result = """&amp; &lt; &gt; &apos; &quot;""".renderAsTestSpan()

result shouldBeEqualTo """& < > ' """"
}

private fun String.renderAsTestSpan() = renderer.render(this).toSpannable().toTestSpan()
}
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,7 @@ class MatrixHtmlPluginConfigure @Inject constructor(private val colorProvider: C

override fun configureHtml(plugin: HtmlPlugin) {
plugin
.addHandler(ListHandlerWithInitialStart())
.addHandler(FontTagHandler())
.addHandler(ParagraphHandler(DimensionConverter(resources)))
.addHandler(MxReplyTagHandler())
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
/*
* Copyright (c) 2022 New Vector Ltd
*
* 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 im.vector.app.features.html;

import androidx.annotation.NonNull;

import org.commonmark.node.ListItem;

import java.util.Arrays;
import java.util.Collection;

import io.noties.markwon.MarkwonConfiguration;
import io.noties.markwon.MarkwonVisitor;
import io.noties.markwon.RenderProps;
import io.noties.markwon.SpanFactory;
import io.noties.markwon.SpannableBuilder;
import io.noties.markwon.core.CoreProps;
import io.noties.markwon.html.HtmlTag;
import io.noties.markwon.html.MarkwonHtmlRenderer;
import io.noties.markwon.html.TagHandler;

/**
* Copied from https://github.com/noties/Markwon/blob/master/markwon-html/src/main/java/io/noties/markwon/html/tag/ListHandler.java#L44
* With a modification on the starting list position
*/
public class ListHandlerWithInitialStart extends TagHandler {

private static final String START_KEY = "start";

@Override
public void handle(
@NonNull MarkwonVisitor visitor,
@NonNull MarkwonHtmlRenderer renderer,
@NonNull HtmlTag tag) {

if (!tag.isBlock()) {
return;
}

final HtmlTag.Block block = tag.getAsBlock();
final boolean ol = "ol".equals(block.name());
final boolean ul = "ul".equals(block.name());

if (!ol && !ul) {
return;
}

final MarkwonConfiguration configuration = visitor.configuration();
final RenderProps renderProps = visitor.renderProps();
final SpanFactory spanFactory = configuration.spansFactory().get(ListItem.class);

// Modified line
int number = Integer.parseInt(block.attributes().containsKey(START_KEY) ? block.attributes().get(START_KEY) : "1");
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this line is the fix for taking into account the starting position


final int bulletLevel = currentBulletListLevel(block);

for (HtmlTag.Block child : block.children()) {

visitChildren(visitor, renderer, child);

if (spanFactory != null && "li".equals(child.name())) {

// insert list item here
if (ol) {
CoreProps.LIST_ITEM_TYPE.set(renderProps, CoreProps.ListItemType.ORDERED);
CoreProps.ORDERED_LIST_ITEM_NUMBER.set(renderProps, number++);
} else {
CoreProps.LIST_ITEM_TYPE.set(renderProps, CoreProps.ListItemType.BULLET);
CoreProps.BULLET_LIST_ITEM_LEVEL.set(renderProps, bulletLevel);
}

SpannableBuilder.setSpans(
visitor.builder(),
spanFactory.getSpans(configuration, renderProps),
child.start(),
child.end());
}
}
}

@NonNull
@Override
public Collection<String> supportedTags() {
return Arrays.asList("ol", "ul");
}

private static int currentBulletListLevel(@NonNull HtmlTag.Block block) {
int level = 0;
while ((block = block.parent()) != null) {
if ("ul".equals(block.name())
|| "ol".equals(block.name())) {
level += 1;
}
}
return level;
}
}