Skip to content

Commit cf22a76

Browse files
authored
Merge pull request #6473 from vector-im/feature/adm/list-initial-value
Fixing numbered lists always starting from 1
2 parents 8a68b31 + ead8cec commit cf22a76

File tree

5 files changed

+286
-0
lines changed

5 files changed

+286
-0
lines changed

changelog.d/4777.bugfix

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Fixes numbered lists always starting from 1
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
/*
2+
* Copyright (c) 2022 New Vector Ltd
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package im.vector.app.core.utils
18+
19+
import android.graphics.Canvas
20+
import android.graphics.Paint
21+
import android.text.Layout
22+
import android.text.Spannable
23+
import androidx.core.text.getSpans
24+
import im.vector.app.features.html.HtmlCodeSpan
25+
import io.mockk.justRun
26+
import io.mockk.mockk
27+
import io.mockk.slot
28+
import io.mockk.verify
29+
import io.noties.markwon.core.spans.EmphasisSpan
30+
import io.noties.markwon.core.spans.OrderedListItemSpan
31+
import io.noties.markwon.core.spans.StrongEmphasisSpan
32+
33+
fun Spannable.toTestSpan(): String {
34+
var output = toString()
35+
readSpansWithContent().forEach {
36+
val tags = it.span.readTags()
37+
val remappedContent = it.span.remapContent(source = this, originalContent = it.content)
38+
output = output.replace(it.content, "${tags.open}$remappedContent${tags.close}")
39+
}
40+
return output
41+
}
42+
43+
private fun Spannable.readSpansWithContent() = getSpans<Any>().map { span ->
44+
val start = getSpanStart(span)
45+
val end = getSpanEnd(span)
46+
SpanWithContent(
47+
content = substring(start, end),
48+
span = span
49+
)
50+
}.reversed()
51+
52+
private fun Any.readTags(): SpanTags {
53+
return when (this::class) {
54+
OrderedListItemSpan::class -> SpanTags("[list item]", "[/list item]")
55+
HtmlCodeSpan::class -> SpanTags("[code]", "[/code]")
56+
StrongEmphasisSpan::class -> SpanTags("[bold]", "[/bold]")
57+
EmphasisSpan::class -> SpanTags("[italic]", "[/italic]")
58+
else -> throw IllegalArgumentException("Unknown ${this::class}")
59+
}
60+
}
61+
62+
private fun Any.remapContent(source: CharSequence, originalContent: String): String {
63+
return when (this::class) {
64+
OrderedListItemSpan::class -> {
65+
val prefix = (this as OrderedListItemSpan).collectNumber(source)
66+
"$prefix$originalContent"
67+
}
68+
else -> originalContent
69+
}
70+
}
71+
72+
private fun OrderedListItemSpan.collectNumber(text: CharSequence): String {
73+
val fakeCanvas = mockk<Canvas>()
74+
val fakeLayout = mockk<Layout>()
75+
justRun { fakeCanvas.drawText(any(), any(), any(), any()) }
76+
val paint = Paint()
77+
drawLeadingMargin(fakeCanvas, paint, 0, 0, 0, 0, 0, text, 0, text.length - 1, true, fakeLayout)
78+
val slot = slot<String>()
79+
verify { fakeCanvas.drawText(capture(slot), any(), any(), any()) }
80+
return slot.captured
81+
}
82+
83+
private data class SpanTags(
84+
val open: String,
85+
val close: String,
86+
)
87+
88+
private data class SpanWithContent(
89+
val content: String,
90+
val span: Any
91+
)
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
/*
2+
* Copyright (c) 2022 New Vector Ltd
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package im.vector.app.features.html
18+
19+
import androidx.core.text.toSpannable
20+
import androidx.test.platform.app.InstrumentationRegistry
21+
import im.vector.app.core.resources.ColorProvider
22+
import im.vector.app.core.utils.toTestSpan
23+
import im.vector.app.features.settings.VectorPreferences
24+
import io.mockk.every
25+
import io.mockk.mockk
26+
import org.amshove.kluent.shouldBeEqualTo
27+
import org.junit.Test
28+
import org.junit.runner.RunWith
29+
import org.junit.runners.JUnit4
30+
import kotlin.text.Typography.nbsp
31+
32+
@RunWith(JUnit4::class)
33+
class EventHtmlRendererTest {
34+
35+
private val context = InstrumentationRegistry.getInstrumentation().targetContext
36+
private val fakeVectorPreferences = mockk<VectorPreferences>().also {
37+
every { it.latexMathsIsEnabled() } returns false
38+
}
39+
40+
private val renderer = EventHtmlRenderer(
41+
MatrixHtmlPluginConfigure(ColorProvider(context), context.resources),
42+
context,
43+
fakeVectorPreferences
44+
)
45+
46+
@Test
47+
fun takesInitialListPositionIntoAccount() {
48+
val result = """<ol start="5"><li>first entry<li></ol>""".renderAsTestSpan()
49+
50+
result shouldBeEqualTo "[list item]5.${nbsp}first entry[/list item]\n"
51+
}
52+
53+
@Test
54+
fun doesNotProcessMarkdownWithinCodeBlocks() {
55+
val result = """<code>__italic__ **bold**</code>""".renderAsTestSpan()
56+
57+
result shouldBeEqualTo "[code]__italic__ **bold**[/code]"
58+
}
59+
60+
@Test
61+
fun doesNotProcessMarkdownBoldAndItalic() {
62+
val result = """__italic__ **bold**""".renderAsTestSpan()
63+
64+
result shouldBeEqualTo "__italic__ **bold**"
65+
}
66+
67+
@Test
68+
fun processesHtmlWithinCodeBlocks() {
69+
val result = """<code><i>italic</i> <b>bold</b></code>""".renderAsTestSpan()
70+
71+
result shouldBeEqualTo "[code][italic]italic[/italic] [bold]bold[/bold][/code]"
72+
}
73+
74+
@Test
75+
fun processesHtmlEntities() {
76+
val result = """&amp; &lt; &gt; &apos; &quot;""".renderAsTestSpan()
77+
78+
result shouldBeEqualTo """& < > ' """"
79+
}
80+
81+
private fun String.renderAsTestSpan() = renderer.render(this).toSpannable().toTestSpan()
82+
}

vector/src/main/java/im/vector/app/features/html/EventHtmlRenderer.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,7 @@ class MatrixHtmlPluginConfigure @Inject constructor(private val colorProvider: C
153153

154154
override fun configureHtml(plugin: HtmlPlugin) {
155155
plugin
156+
.addHandler(ListHandlerWithInitialStart())
156157
.addHandler(FontTagHandler())
157158
.addHandler(ParagraphHandler(DimensionConverter(resources)))
158159
.addHandler(MxReplyTagHandler())
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
/*
2+
* Copyright (c) 2022 New Vector Ltd
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package im.vector.app.features.html;
18+
19+
import androidx.annotation.NonNull;
20+
21+
import org.commonmark.node.ListItem;
22+
23+
import java.util.Arrays;
24+
import java.util.Collection;
25+
26+
import io.noties.markwon.MarkwonConfiguration;
27+
import io.noties.markwon.MarkwonVisitor;
28+
import io.noties.markwon.RenderProps;
29+
import io.noties.markwon.SpanFactory;
30+
import io.noties.markwon.SpannableBuilder;
31+
import io.noties.markwon.core.CoreProps;
32+
import io.noties.markwon.html.HtmlTag;
33+
import io.noties.markwon.html.MarkwonHtmlRenderer;
34+
import io.noties.markwon.html.TagHandler;
35+
36+
/**
37+
* Copied from https://github.com/noties/Markwon/blob/master/markwon-html/src/main/java/io/noties/markwon/html/tag/ListHandler.java#L44
38+
* With a modification on the starting list position
39+
*/
40+
public class ListHandlerWithInitialStart extends TagHandler {
41+
42+
private static final String START_KEY = "start";
43+
44+
@Override
45+
public void handle(
46+
@NonNull MarkwonVisitor visitor,
47+
@NonNull MarkwonHtmlRenderer renderer,
48+
@NonNull HtmlTag tag) {
49+
50+
if (!tag.isBlock()) {
51+
return;
52+
}
53+
54+
final HtmlTag.Block block = tag.getAsBlock();
55+
final boolean ol = "ol".equals(block.name());
56+
final boolean ul = "ul".equals(block.name());
57+
58+
if (!ol && !ul) {
59+
return;
60+
}
61+
62+
final MarkwonConfiguration configuration = visitor.configuration();
63+
final RenderProps renderProps = visitor.renderProps();
64+
final SpanFactory spanFactory = configuration.spansFactory().get(ListItem.class);
65+
66+
// Modified line
67+
int number = Integer.parseInt(block.attributes().containsKey(START_KEY) ? block.attributes().get(START_KEY) : "1");
68+
69+
final int bulletLevel = currentBulletListLevel(block);
70+
71+
for (HtmlTag.Block child : block.children()) {
72+
73+
visitChildren(visitor, renderer, child);
74+
75+
if (spanFactory != null && "li".equals(child.name())) {
76+
77+
// insert list item here
78+
if (ol) {
79+
CoreProps.LIST_ITEM_TYPE.set(renderProps, CoreProps.ListItemType.ORDERED);
80+
CoreProps.ORDERED_LIST_ITEM_NUMBER.set(renderProps, number++);
81+
} else {
82+
CoreProps.LIST_ITEM_TYPE.set(renderProps, CoreProps.ListItemType.BULLET);
83+
CoreProps.BULLET_LIST_ITEM_LEVEL.set(renderProps, bulletLevel);
84+
}
85+
86+
SpannableBuilder.setSpans(
87+
visitor.builder(),
88+
spanFactory.getSpans(configuration, renderProps),
89+
child.start(),
90+
child.end());
91+
}
92+
}
93+
}
94+
95+
@NonNull
96+
@Override
97+
public Collection<String> supportedTags() {
98+
return Arrays.asList("ol", "ul");
99+
}
100+
101+
private static int currentBulletListLevel(@NonNull HtmlTag.Block block) {
102+
int level = 0;
103+
while ((block = block.parent()) != null) {
104+
if ("ul".equals(block.name())
105+
|| "ol".equals(block.name())) {
106+
level += 1;
107+
}
108+
}
109+
return level;
110+
}
111+
}

0 commit comments

Comments
 (0)