From 19aa262eda2e7a7e146ae802764ed1a3175912c4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jorge=20Mart=C3=ADn?= Date: Tue, 16 Jul 2024 16:54:11 +0200 Subject: [PATCH 1/3] Use Jsoup instead of TagSoup for HTML parsing. Being way more flexible, it allows us to simplify the parsing *a lot* and do checks we couldn't do before, like one which was needed to know when extra line breaks between inline nodes and block ones are needed. --- .../element/wysiwyg/compose/MainActivity.kt | 3 + platforms/android/gradle/libs.versions.toml | 2 +- platforms/android/library/build.gradle | 4 +- ...InterceptInputConnectionIntegrationTest.kt | 35 +- .../wysiwyg/utils/HtmlToSpansParser.kt | 559 +++++------------- 5 files changed, 180 insertions(+), 423 deletions(-) diff --git a/platforms/android/example-compose/src/main/java/io/element/wysiwyg/compose/MainActivity.kt b/platforms/android/example-compose/src/main/java/io/element/wysiwyg/compose/MainActivity.kt index 86eafd1da..5717dc018 100644 --- a/platforms/android/example-compose/src/main/java/io/element/wysiwyg/compose/MainActivity.kt +++ b/platforms/android/example-compose/src/main/java/io/element/wysiwyg/compose/MainActivity.kt @@ -81,6 +81,9 @@ class MainActivity : ComponentActivity() { var linkDialogAction by remember { mutableStateOf(null) } val coroutineScope = rememberCoroutineScope() + LaunchedEffect(state.messageHtml) { + Timber.d("Message HTML: '${state.messageHtml}'") + } val htmlText = htmlConverter.fromHtmlToSpans(state.messageHtml) linkDialogAction?.let { linkAction -> diff --git a/platforms/android/gradle/libs.versions.toml b/platforms/android/gradle/libs.versions.toml index 474dbe08d..d57e659d4 100644 --- a/platforms/android/gradle/libs.versions.toml +++ b/platforms/android/gradle/libs.versions.toml @@ -57,7 +57,7 @@ google-material = { module="com.google.android.material:material", version.ref=" # Misc timber = { module="com.jakewharton.timber:timber", version.ref="timber" } -tagsoup = { module="org.ccil.cowan.tagsoup:tagsoup", version.ref="tagsoup" } +jsoup = "org.jsoup:jsoup:1.18.1" molecule-runtime = { module = "app.cash.molecule:molecule-runtime", version.ref = "molecule" } # Test diff --git a/platforms/android/library/build.gradle b/platforms/android/library/build.gradle index ff64a94f3..b4cde4683 100644 --- a/platforms/android/library/build.gradle +++ b/platforms/android/library/build.gradle @@ -90,8 +90,8 @@ dependencies { implementation libs.timber - // XML Parsing - api libs.tagsoup + // HTML Parsing + api libs.jsoup implementation libs.androidx.core implementation libs.androidx.appcompat diff --git a/platforms/android/library/src/androidTest/java/io/element/android/wysiwyg/inputhandlers/InterceptInputConnectionIntegrationTest.kt b/platforms/android/library/src/androidTest/java/io/element/android/wysiwyg/inputhandlers/InterceptInputConnectionIntegrationTest.kt index f03490deb..ed2975570 100644 --- a/platforms/android/library/src/androidTest/java/io/element/android/wysiwyg/inputhandlers/InterceptInputConnectionIntegrationTest.kt +++ b/platforms/android/library/src/androidTest/java/io/element/android/wysiwyg/inputhandlers/InterceptInputConnectionIntegrationTest.kt @@ -26,7 +26,8 @@ class InterceptInputConnectionIntegrationTest { private val viewModel = EditorViewModel( provideComposer = { newComposerModel() }, ).also { - it.htmlConverter = HtmlConverter.Factory.create(context = app, + it.htmlConverter = HtmlConverter.Factory.create( + context = app, styleConfig = styleConfig, mentionDisplayHandler = null, ) @@ -57,7 +58,7 @@ class InterceptInputConnectionIntegrationTest { textView.text.dumpSpans(), equalTo( listOf( "hello: android.widget.TextView.ChangeWatcher (0-5) fl=#6553618", - "hello: android.text.style.StyleSpan (0-5) fl=#33", + "hello: android.text.style.StyleSpan (0-5) fl=#17", "hello: android.text.method.TextKeyListener (0-5) fl=#18", "hello: android.text.style.UnderlineSpan (0-5) fl=#289", "hello: android.view.inputmethod.ComposingText (0-5) fl=#289", @@ -80,7 +81,7 @@ class InterceptInputConnectionIntegrationTest { assertThat( textView.text.dumpSpans(), equalTo( baseEditedSpans.toMutableList().apply { - add(1, "world: android.text.style.StyleSpan (0-5) fl=#33") + add(1, "world: android.text.style.StyleSpan (0-5) fl=#17") } ) ) @@ -97,7 +98,7 @@ class InterceptInputConnectionIntegrationTest { assertThat( textView.text.dumpSpans(), equalTo( baseEditedSpans.toMutableList().apply { - add(1, "world: android.text.style.UnderlineSpan (0-5) fl=#33") + add(1, "world: android.text.style.UnderlineSpan (0-5) fl=#17") } ) ) @@ -114,7 +115,7 @@ class InterceptInputConnectionIntegrationTest { assertThat( textView.text.dumpSpans(), equalTo( baseEditedSpans.toMutableList().apply { - add(1, "world: android.text.style.StrikethroughSpan (0-5) fl=#33") + add(1, "world: android.text.style.StrikethroughSpan (0-5) fl=#17") } ) ) @@ -131,7 +132,7 @@ class InterceptInputConnectionIntegrationTest { assertThat( textView.text.dumpSpans(), equalTo( baseEditedSpans.toMutableList().apply { - add(1, "world: io.element.android.wysiwyg.view.spans.InlineCodeSpan (0-5) fl=#33") + add(1, "world: io.element.android.wysiwyg.view.spans.InlineCodeSpan (0-5) fl=#17") } ) ) @@ -147,7 +148,7 @@ class InterceptInputConnectionIntegrationTest { textView.text.dumpSpans(), equalTo( listOf( "hello: android.widget.TextView.ChangeWatcher (0-5) fl=#6553618", - "hello: io.element.android.wysiwyg.view.spans.OrderedListSpan (0-5) fl=#34", + "hello: io.element.android.wysiwyg.view.spans.OrderedListSpan (0-5) fl=#17", "hello: android.text.style.UnderlineSpan (0-5) fl=#289", "hello: android.view.inputmethod.ComposingText (0-5) fl=#289", "hello: android.text.method.TextKeyListener (0-5) fl=#18", @@ -173,7 +174,7 @@ class InterceptInputConnectionIntegrationTest { textView.text.dumpSpans().joinToString(",\n"), equalTo( """ hello: android.widget.TextView.ChangeWatcher (0-5) fl=#6553618, - hello: io.element.android.wysiwyg.view.spans.UnorderedListSpan (0-5) fl=#34, + hello: io.element.android.wysiwyg.view.spans.UnorderedListSpan (0-5) fl=#17, hello: android.text.style.UnderlineSpan (0-5) fl=#289, hello: android.view.inputmethod.ComposingText (0-5) fl=#289, hello: android.text.method.TextKeyListener (0-5) fl=#18, @@ -195,7 +196,7 @@ class InterceptInputConnectionIntegrationTest { textView.text.dumpSpans().joinToString(",\n"), equalTo( """ hello: android.widget.TextView.ChangeWatcher (0-5) fl=#6553618, - hello: io.element.android.wysiwyg.view.spans.UnorderedListSpan (0-5) fl=#34, + hello: io.element.android.wysiwyg.view.spans.UnorderedListSpan (0-5) fl=#17, hello: android.text.style.UnderlineSpan (0-5) fl=#289, hello: android.view.inputmethod.ComposingText (0-5) fl=#289, hello: android.text.method.TextKeyListener (0-5) fl=#18, @@ -221,7 +222,7 @@ class InterceptInputConnectionIntegrationTest { textView.text.dumpSpans().joinToString(",\n"), equalTo( """ hello: android.widget.TextView.ChangeWatcher (0-5) fl=#6553618, - hello: io.element.android.wysiwyg.view.spans.OrderedListSpan (0-5) fl=#34, + hello: io.element.android.wysiwyg.view.spans.OrderedListSpan (0-5) fl=#17, hello: android.text.style.UnderlineSpan (0-5) fl=#289, hello: android.view.inputmethod.ComposingText (0-5) fl=#289, hello: android.text.method.TextKeyListener (0-5) fl=#18, @@ -244,7 +245,7 @@ class InterceptInputConnectionIntegrationTest { textView.text.dumpSpans(), equalTo( listOf( "😋😋: android.widget.TextView.ChangeWatcher (0-4) fl=#6553618", - "😋😋: io.element.android.wysiwyg.view.spans.OrderedListSpan (0-4) fl=#34", + "😋😋: io.element.android.wysiwyg.view.spans.OrderedListSpan (0-4) fl=#17", "😋😋: android.text.style.UnderlineSpan (0-4) fl=#289", "😋😋: android.view.inputmethod.ComposingText (0-4) fl=#289", "😋😋: android.text.method.TextKeyListener (0-4) fl=#18", @@ -270,7 +271,7 @@ class InterceptInputConnectionIntegrationTest { textView.text.dumpSpans(), equalTo( listOf( "hello: android.widget.TextView.ChangeWatcher (0-5) fl=#6553618", - "hello: io.element.android.wysiwyg.view.spans.CodeBlockSpan (0-5) fl=#33", + "hello: io.element.android.wysiwyg.view.spans.CodeBlockSpan (0-5) fl=#17", "hello: android.text.style.UnderlineSpan (0-5) fl=#289", "hello: android.view.inputmethod.ComposingText (0-5) fl=#289", "hello: android.text.method.TextKeyListener (0-5) fl=#18", @@ -317,7 +318,7 @@ class InterceptInputConnectionIntegrationTest { textView.text.dumpSpans(), equalTo( listOf( "Test\n$NBSP: android.widget.TextView.ChangeWatcher (0-6) fl=#6553618", - "Test\n$NBSP: io.element.android.wysiwyg.view.spans.CodeBlockSpan (0-6) fl=#33", + "Test\n$NBSP: io.element.android.wysiwyg.view.spans.CodeBlockSpan (0-6) fl=#17", "$NBSP: io.element.android.wysiwyg.view.spans.ExtraCharacterSpan (5-6) fl=#17", "Test\n$NBSP: android.text.method.TextKeyListener (0-6) fl=#18", "Test\n$NBSP: android.widget.Editor.SpanController (0-6) fl=#18", @@ -335,7 +336,7 @@ class InterceptInputConnectionIntegrationTest { textView.text.dumpSpans(), equalTo( listOf( "Test\n$NBSP: android.widget.TextView.ChangeWatcher (0-6) fl=#6553618", - "Test: io.element.android.wysiwyg.view.spans.CodeBlockSpan (0-4) fl=#33", + "Test: io.element.android.wysiwyg.view.spans.CodeBlockSpan (0-4) fl=#17", "$NBSP: io.element.android.wysiwyg.view.spans.ExtraCharacterSpan (5-6) fl=#17", "Test\n$NBSP: android.text.method.TextKeyListener (0-6) fl=#18", "Test\n$NBSP: android.widget.Editor.SpanController (0-6) fl=#18", @@ -360,7 +361,7 @@ class InterceptInputConnectionIntegrationTest { textView.text.dumpSpans().joinToString(",\n"), equalTo( """ hello: android.widget.TextView.ChangeWatcher (0-5) fl=#6553618, - hello: io.element.android.wysiwyg.view.spans.QuoteSpan (0-5) fl=#33, + hello: io.element.android.wysiwyg.view.spans.QuoteSpan (0-5) fl=#17, hello: android.text.style.UnderlineSpan (0-5) fl=#289, hello: android.view.inputmethod.ComposingText (0-5) fl=#289, hello: android.text.method.TextKeyListener (0-5) fl=#18, @@ -407,8 +408,8 @@ class InterceptInputConnectionIntegrationTest { textView.text.dumpSpans(), equalTo( listOf( "Test\n$NBSP: android.widget.TextView.ChangeWatcher (0-6) fl=#6553618", - "Test\n$NBSP: io.element.android.wysiwyg.view.spans.QuoteSpan (0-6) fl=#33", "$NBSP: io.element.android.wysiwyg.view.spans.ExtraCharacterSpan (5-6) fl=#17", + "Test\n$NBSP: io.element.android.wysiwyg.view.spans.QuoteSpan (0-6) fl=#17", "Test\n$NBSP: android.text.method.TextKeyListener (0-6) fl=#18", "Test\n$NBSP: android.widget.Editor.SpanController (0-6) fl=#18", ": android.text.Selection.START (5-5) fl=#546", @@ -425,7 +426,7 @@ class InterceptInputConnectionIntegrationTest { textView.text.dumpSpans(), equalTo( listOf( "Test\n$NBSP: android.widget.TextView.ChangeWatcher (0-6) fl=#6553618", - "Test: io.element.android.wysiwyg.view.spans.QuoteSpan (0-4) fl=#33", + "Test: io.element.android.wysiwyg.view.spans.QuoteSpan (0-4) fl=#17", "$NBSP: io.element.android.wysiwyg.view.spans.ExtraCharacterSpan (5-6) fl=#17", "Test\n$NBSP: android.text.method.TextKeyListener (0-6) fl=#18", "Test\n$NBSP: android.widget.Editor.SpanController (0-6) fl=#18", diff --git a/platforms/android/library/src/main/java/io/element/android/wysiwyg/utils/HtmlToSpansParser.kt b/platforms/android/library/src/main/java/io/element/android/wysiwyg/utils/HtmlToSpansParser.kt index dbe5b74b3..c06b291de 100644 --- a/platforms/android/library/src/main/java/io/element/android/wysiwyg/utils/HtmlToSpansParser.kt +++ b/platforms/android/library/src/main/java/io/element/android/wysiwyg/utils/HtmlToSpansParser.kt @@ -4,17 +4,16 @@ import android.graphics.Typeface import android.text.Editable import android.text.SpannableStringBuilder import android.text.Spanned -import android.text.style.ParagraphStyle import android.text.style.StrikethroughSpan import android.text.style.StyleSpan import android.text.style.UnderlineSpan -import androidx.core.text.getSpans +import androidx.core.text.buildSpannedString +import androidx.core.text.inSpans import io.element.android.wysiwyg.BuildConfig import io.element.android.wysiwyg.display.MentionDisplayHandler import io.element.android.wysiwyg.display.TextDisplay import io.element.android.wysiwyg.view.StyleConfig import io.element.android.wysiwyg.view.models.InlineFormat -import io.element.android.wysiwyg.view.spans.BlockSpan import io.element.android.wysiwyg.view.spans.CodeBlockSpan import io.element.android.wysiwyg.view.spans.CustomMentionSpan import io.element.android.wysiwyg.view.spans.ExtraCharacterSpan @@ -25,12 +24,9 @@ import io.element.android.wysiwyg.view.spans.PillSpan import io.element.android.wysiwyg.view.spans.PlainAtRoomMentionDisplaySpan import io.element.android.wysiwyg.view.spans.QuoteSpan import io.element.android.wysiwyg.view.spans.UnorderedListSpan -import org.ccil.cowan.tagsoup.Parser -import org.xml.sax.Attributes -import org.xml.sax.ContentHandler -import org.xml.sax.InputSource -import org.xml.sax.Locator -import java.io.StringReader +import org.jsoup.Jsoup +import org.jsoup.nodes.Element +import org.jsoup.nodes.TextNode import kotlin.math.roundToInt /** @@ -46,310 +42,171 @@ internal class HtmlToSpansParser( private val styleConfig: StyleConfig, private val mentionDisplayHandler: MentionDisplayHandler?, private val isMention: ((text: String, url: String) -> Boolean)? = null, -) : ContentHandler { - - /** - * Class used to hold information about what spans should be added to the text, keeping the - * natural order of insertion that would otherwise be broken by the parsing order. - */ - private class PendingSpan( - val span: T, - val start: Int, - val end: Int, - val flags: Int, - val isPlaceholder: Boolean, - ) - - // Spans created to be used as 'marks' while parsing - private sealed interface PlaceholderSpan { - data class Hyperlink(val link: String, val data: Map) : PlaceholderSpan - sealed interface ListBlock : PlaceholderSpan { - class Ordered : ListBlock - class Unordered : ListBlock - } - - class CodeBlock : PlaceholderSpan - class Quote : PlaceholderSpan - class Paragraph : BlockSpan, PlaceholderSpan - data class ListItem( - val ordered: Boolean, val order: Int? = null - ) : BlockSpan, PlaceholderSpan - } - +) { /** - * Child tags are parsed before their parents, causing them to be added to the [text] - * also in reversed order. - * In example, in: - * ``` - *
  • text
  • - * ``` - * The `blockquote` tag will be parsed and its span will be added first to the text, then the - * `li` one will be parsed and its span will be added. However, as they were added in reversed - * order (`quote > li` instead of `li > quote`), it will appear as a list item inside a quote - * when Android renders the resulting text. - * - * To fix that, we're creating this list of Spans to be added to the text: - * 1. When we parse the start tag, we set a placeholder span to 'book' the position. - * 2. When we parse the end tag, we replace that placeholder with the real span, keeping the - * starting position. - * 3. Once we've parsed the whole HTML, we apply these spans to the text in order. - * - * *Note*: this is only needed for block spans, as inline spans can be rendered in any order. + * Convert the HTML string into a [Spanned] text. */ - private val spansToAdd = mutableListOf>() - - private val parser = Parser().also { it.contentHandler = this } - private val text = SpannableStringBuilder() - fun convert(): Spanned { - spansToAdd.clear() - parser.parse(InputSource(StringReader(html))) - if (text.isNotEmpty()) { - for (spanToAdd in spansToAdd) { - val start = spanToAdd.start.coerceIn(0, text.length) - val end = spanToAdd.end.coerceIn(0, text.length) - text.setSpan(spanToAdd.span, start, end, spanToAdd.flags) - } - text.removePlaceholderSpans() - text.addAtRoomSpans() + val dom = Jsoup.parse(html) + val text = buildSpannedString { + val body = dom.body() + parseChildren(body) + addAtRoomSpans() } if (BuildConfig.DEBUG) text.assertOnlyAllowedSpans() return text } - override fun setDocumentLocator(locator: Locator?) {} - - override fun startDocument() {} + private fun SpannableStringBuilder.parseChildren(element: Element) { + for (child in element.childNodes()) { + when (child) { + is Element -> parseElement(child) + is TextNode -> parseTextNode(child) + } + } + } - override fun endDocument() {} + private fun SpannableStringBuilder.parseElement(element: Element) { + when (element.tagName()) { + "a" -> parseLink(element) + "b", "strong" -> parseInlineFormatting(element, InlineFormat.Bold) + "i", "em" -> parseInlineFormatting(element, InlineFormat.Italic) + "u" -> parseInlineFormatting(element, InlineFormat.Underline) + "del" -> parseInlineFormatting(element, InlineFormat.StrikeThrough) + "code" -> parseInlineCode(element) + "ul", "ol" -> parseChildren(element) + "li" -> parseListItem(element) + "pre" -> parseCodeBlock(element) + "blockquote" -> parseQuote(element) + "p" -> parseParagraph(element) + "br" -> parseLineBreak(element) + } + } - override fun startPrefixMapping(prefix: String?, uri: String?) {} + // region: Handle parsing of tags into spans - override fun endPrefixMapping(prefix: String?) {} + private fun SpannableStringBuilder.parseTextNode(child: TextNode) { + val text = child.wholeText + if (text.isEmpty()) return - override fun startElement(uri: String?, localName: String, qName: String?, atts: Attributes?) { - handleStartTag(localName, atts) + val previousSibling = child.previousSibling() as? Element + if (previousSibling != null && previousSibling.isBlock) { + append('\n') + } + append(text) } - override fun endElement(uri: String?, localName: String, qName: String?) { - handleEndTag(localName) + private fun SpannableStringBuilder.parseInlineFormatting(element: Element, inlineFormat: InlineFormat) { + val span = when (inlineFormat) { + InlineFormat.Bold -> StyleSpan(Typeface.BOLD) + InlineFormat.Italic -> StyleSpan(Typeface.ITALIC) + InlineFormat.Underline -> UnderlineSpan() + InlineFormat.StrikeThrough -> StrikethroughSpan() + InlineFormat.InlineCode -> return + } + inSpans(span) { + parseChildren(element) + } } - override fun characters(ch: CharArray, start: Int, length: Int) { - for (i in start until start + length) { - val char = ch[i] - text.append(char) + private fun SpannableStringBuilder.parseLineBreak(element: Element) { + if (element.previousElementSibling()?.isBlock == true) { + append('\n') } + append('\n') } - override fun ignorableWhitespace(ch: CharArray, start: Int, length: Int) {} - - override fun processingInstruction(target: String?, data: String?) {} - - override fun skippedEntity(name: String?) {} - - private fun handleStartTag(name: String, attrs: Attributes?) { - when (name) { - "b", "strong" -> handleFormatStartTag(InlineFormat.Bold) - "i", "em" -> handleFormatStartTag(InlineFormat.Italic) - "u" -> handleFormatStartTag(InlineFormat.Underline) - "del" -> handleFormatStartTag(InlineFormat.StrikeThrough) - "code" -> { - if (getLastPending() != null) return - handleFormatStartTag(InlineFormat.InlineCode) - } - - "a" -> { - val url = attrs?.getValue("href") ?: return - val data = buildMap { - for (i in 0..attrs.length) { - val key = attrs.getLocalName(i) ?: continue - set(key, attrs.getValue(i)) - } - } - handleHyperlinkStart(url, data) - } - - "ul", "ol" -> { - addLeadingLineBreakIfNeeded(text.length) - val mark: PlaceholderSpan = if (name == "ol") { - PlaceholderSpan.ListBlock.Ordered() - } else { - PlaceholderSpan.ListBlock.Unordered() - } - addPlaceHolderSpan(mark) - } + private fun SpannableStringBuilder.parseParagraph(element: Element) { + addLeadingLineBreakForBlockNode(element) + val start = this.length + parseChildren(element) + handleNbspInBlock(element, start, length) + } - "li" -> { - addLeadingLineBreakIfNeeded(text.length) - val lastListBlock = getLastPending() ?: return - val start = text.getSpanStart(lastListBlock) - val newItem = when (lastListBlock.span) { - is PlaceholderSpan.ListBlock.Ordered -> { - val lastListItem = spansToAdd.findLast { - it.span is OrderedListSpan && it.start >= start - }?.span as? OrderedListSpan - val order = (lastListItem?.order ?: 0) + 1 - PlaceholderSpan.ListItem(true, order) - } - - is PlaceholderSpan.ListBlock.Unordered -> PlaceholderSpan.ListItem(false) - } - addPlaceHolderSpan(newItem) - } + private fun SpannableStringBuilder.parseQuote(element: Element) { + addLeadingLineBreakForBlockNode(element) + val start = this.length + inSpans( + QuoteSpan( + // TODO provide these values from the style config + indicatorColor = 0xC0A0A0A0.toInt(), + indicatorWidth = 4.dpToPx().toInt(), + indicatorPadding = 6.dpToPx().toInt(), + margin = 10.dpToPx().toInt(), + ) + ) { + parseChildren(element) + } - "pre" -> { - addLeadingLineBreakIfNeeded(text.length) - val placeholder = PlaceholderSpan.CodeBlock() - addPlaceHolderSpan(placeholder) - } + handleNbspInBlock(element, start, length) + } - "blockquote" -> { - addLeadingLineBreakIfNeeded(text.length) - val placeholder = PlaceholderSpan.Quote() - addPlaceHolderSpan(placeholder) - } + private fun SpannableStringBuilder.parseCodeBlock(element: Element) { + addLeadingLineBreakForBlockNode(element) + val start = this.length + inSpans( + CodeBlockSpan( + leadingMargin = styleConfig.codeBlock.leadingMargin, + verticalPadding = styleConfig.codeBlock.verticalPadding, + relativeSizeProportion = styleConfig.codeBlock.relativeTextSize, + ) + ) { + append(element.wholeText()) + } - "p" -> { - addLeadingLineBreakIfNeeded(text.length) - val placeholder = PlaceholderSpan.Paragraph() - addPlaceHolderSpan(placeholder) + handleNbspInBlock(element, start, length) + // Handle NBSPs for new lines inside the preformatted text + for (i in start + 1 until length) { + if (this[i] == NBSP) { + setSpan(ExtraCharacterSpan(), i, i + 1, Spanned.SPAN_INCLUSIVE_EXCLUSIVE) } } } - private fun handleEndTag(name: String) { - when (name) { - "br" -> { - addLeadingLineBreakIfNeeded(text.length) - text.append("\n") - } - - "b", "strong" -> handleFormatEndTag(InlineFormat.Bold) - "i", "em" -> handleFormatEndTag(InlineFormat.Italic) - "u" -> handleFormatEndTag(InlineFormat.Underline) - "del" -> handleFormatEndTag(InlineFormat.StrikeThrough) - "code" -> handleFormatEndTag(InlineFormat.InlineCode) - "a" -> handleHyperlinkEnd() - "li" -> { - val last = getLastPending() ?: return - val start = last.start - - handleNbspInBlock(start) - - val span = createListSpan(last = last.span) - replacePlaceholderWithPendingSpan( - placeholder = last.span, - span = span, - start = start, - flags = Spanned.SPAN_EXCLUSIVE_INCLUSIVE - ) - } - - "pre" -> { - val last = getLastPending() ?: return - val start = last.start - - handleNbspInBlock(start) - for (i in start + 1 until text.length) { - if (text[i] == NBSP) { - // Extra char to properly render empty new lines in code blocks - handleNbspInBlock(i) - } - } - - val codeSpan = CodeBlockSpan( - leadingMargin = styleConfig.codeBlock.leadingMargin, - verticalPadding = styleConfig.codeBlock.verticalPadding, - relativeSizeProportion = styleConfig.codeBlock.relativeTextSize, - ) - replacePlaceholderWithPendingSpan( - placeholder = last.span, - span = codeSpan, - start = start, - flags = Spanned.SPAN_EXCLUSIVE_EXCLUSIVE - ) - } - - "blockquote" -> { - val last = getLastPending() ?: return - val start = last.start - - handleNbspInBlock(start) + private fun SpannableStringBuilder.parseListItem(element: Element) { + val gapWidth = styleConfig.bulletList.bulletGapWidth.roundToInt() + val bulletRadius = styleConfig.bulletList.bulletRadius.roundToInt() - val quoteSpan = QuoteSpan( - indicatorColor = 0xC0A0A0A0.toInt(), - indicatorWidth = 4.dpToPx().toInt(), - indicatorPadding = 6.dpToPx().toInt(), - margin = 10.dpToPx().toInt(), - ) - replacePlaceholderWithPendingSpan( - placeholder = last.span, - span = quoteSpan, - start = start, - flags = Spanned.SPAN_EXCLUSIVE_EXCLUSIVE - ) + val listParent = element.parents().find { it.tagName() == "ul" || it.tagName() == "ol" } + val span = when (listParent?.tagName()) { + "ul" -> UnorderedListSpan(gapWidth, bulletRadius) + "ol" -> { + val typeface = Typeface.defaultFromStyle(Typeface.NORMAL) + val textSize = 16.dpToPx() + val order = (element.parent()?.select("li")?.indexOf(element) ?: 0) + 1 + OrderedListSpan(typeface, textSize, order, gapWidth) } - "p" -> { - val last = getLastPending() ?: return - val start = last.start - - handleNbspInBlock(start) - - replacePlaceholderWithPendingSpan( - placeholder = last.span, start = start, flags = Spanned.SPAN_EXCLUSIVE_EXCLUSIVE - ) - } + else -> return } - } - - // region: Handle parsing of tags into spans - - private fun handleFormatStartTag(format: InlineFormat) { - addPlaceHolderSpan(format) - } - - private fun handleFormatEndTag(format: InlineFormat) { - val last = getLastPending(format::class.java) ?: return - val span: Any = when (format) { - InlineFormat.Bold -> StyleSpan(Typeface.BOLD) - InlineFormat.Italic -> StyleSpan(Typeface.ITALIC) - InlineFormat.Underline -> UnderlineSpan() - InlineFormat.StrikeThrough -> StrikethroughSpan() - InlineFormat.InlineCode -> InlineCodeSpan( - relativeSizeProportion = styleConfig.inlineCode.relativeTextSize - ) + addLeadingLineBreakForBlockNode(element) + inSpans(span) { + parseChildren(element) } - replacePlaceholderWithPendingSpan( - last.span, span, last.start, text.length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE - ) } - private fun handleHyperlinkStart(url: String, data: Map) { - val hyperlink = PlaceholderSpan.Hyperlink(url, data) - addPlaceHolderSpan(hyperlink) + private fun SpannableStringBuilder.parseInlineCode(element: Element) { + if (element.parents().none { it.tagName() == "pre" }) { + inSpans(InlineCodeSpan(relativeSizeProportion = styleConfig.inlineCode.relativeTextSize)) { + parseChildren(element) + } + } else { + parseChildren(element) + } } - private fun handleHyperlinkEnd() { - val last = getLastPending() ?: return - val url = last.span.link - val innerText = text.subSequence(last.start, text.length).toString() - - // Invalid mention, it's not mapped to any text + private fun SpannableStringBuilder.parseLink(element: Element) { + val start = this.length + val innerText = element.text() if (innerText.isEmpty()) return - val isMention = isMention?.invoke(innerText, url) == true || - last.span.data.containsKey("data-mention-type") - - // If the link is a mention, tag all but the first character of the anchor text with - // ExtraCharacterSpans. These characters will then be taken into account when translating - // between editor and composer model indices (see [EditorIndexMapper]). - if (isMention && text.length > 1) { - addPendingSpan( - ExtraCharacterSpan(), last.start + 1, text.length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE - ) + val url = element.attr("href") ?: return + val data = buildMap { + for (attr in element.attributes()) { + set(attr.key, attr.value) + } } + val isMention = isMention?.invoke(innerText, url) == true || data.containsKey("data-mention-type") val textDisplay = if (isMention) { mentionDisplayHandler?.resolveMentionDisplay(innerText, url) ?: TextDisplay.Plain @@ -357,153 +214,55 @@ internal class HtmlToSpansParser( TextDisplay.Plain } - when (textDisplay) { - is TextDisplay.Custom -> { - val span = CustomMentionSpan(textDisplay.customSpan, url) - replacePlaceholderWithPendingSpan( - last.span, span, last.start, text.length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE - ) - } - + val span = when (textDisplay) { + is TextDisplay.Custom -> CustomMentionSpan(textDisplay.customSpan, url) TextDisplay.Pill -> { val pillBackground = styleConfig.pill.backgroundColor - val span = PillSpan(pillBackground, url) - replacePlaceholderWithPendingSpan( - last.span, span, last.start, text.length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE - ) + PillSpan(pillBackground, url) } - - TextDisplay.Plain -> { - val span = LinkSpan((last.span).link) - replacePlaceholderWithPendingSpan( - last.span, span, last.start, text.length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE + TextDisplay.Plain -> LinkSpan(url) + } + inSpans(span) { + parseChildren(element) + + // If the link is a mention, tag all but the first character of the anchor text with + // ExtraCharacterSpans. These characters will then be taken into account when translating + // between editor and composer model indices (see [EditorIndexMapper]). + if (isMention && this.length > 1) { + setSpan( + ExtraCharacterSpan(), start + 1, this.length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE ) } } - - } - - private fun createListSpan(last: PlaceholderSpan.ListItem): ParagraphStyle { - val gapWidth = styleConfig.bulletList.bulletGapWidth.roundToInt() - val bulletRadius = styleConfig.bulletList.bulletRadius.roundToInt() - - return if (last.ordered) { - // TODO: provide typeface and textSize somehow - val typeface = Typeface.defaultFromStyle(Typeface.NORMAL) - val textSize = 16.dpToPx() - OrderedListSpan(typeface, textSize, last.order ?: 1, gapWidth) - } else { - UnorderedListSpan(gapWidth, bulletRadius) - } } // endregion // region: Utils for whitespaces and indexes - /** - * Add a line break for the current block element if needed. - */ - private fun addLeadingLineBreakIfNeeded(start: Int): Int { - val previousBlock = spansToAdd.findLast { - it.span is BlockSpan && !it.isPlaceholder && (it.start >= start - 1 || it.end <= start) - } - return if (previousBlock == null) { - start - } else { - val previousBlockEnd = previousBlock.end - if (previousBlockEnd == start) { - text.insert(start, "\n") - start + 1 - } else { - start - } - } - } - /** * Either add an extra NBSP character if missing in the current block element, or, if a NBSP * character exists, set it as extra character so it's ignored when mapping indexes. */ - private fun handleNbspInBlock(pos: Int) { - if (pos == text.length) { - // If there was no NBSP char, add a new one as an extra character - text.append(NBSP) - addPendingSpan(ExtraCharacterSpan(), pos, pos + 1, Spanned.SPAN_INCLUSIVE_EXCLUSIVE) - } else if (text.length == pos + 1 && text[pos] == NBSP) { - // If there was one, set it as an extra character - addPendingSpan(ExtraCharacterSpan(), pos, pos + 1, Spanned.SPAN_INCLUSIVE_EXCLUSIVE) + private fun SpannableStringBuilder.handleNbspInBlock(element: Element, start: Int, end: Int) { + if (!element.isBlock) return + + if (element.childNodes().isEmpty()) { + this.append(NBSP) + setSpan(ExtraCharacterSpan(), end - 1, end, Spanned.SPAN_INCLUSIVE_EXCLUSIVE) + } else if (end - start == 1 && this.getOrNull(start) in listOf(' ', NBSP)) { + setSpan(ExtraCharacterSpan(), start, end, Spanned.SPAN_INCLUSIVE_EXCLUSIVE) } } - // endregion - - // region: Handle span placeholders for block spans - - /** - * Used to add a placeholder span to be replaced in the future with its real version. - * This is used to 'book' a position inside the list of pending spans when we read a starting - * HTML tag so we can replace it later with its real version and range in the text. - */ - private fun addPlaceHolderSpan( - span: T, start: Int = text.length, end: Int = text.length - ) { - val pendingSpan = PendingSpan(span, start, end, 0, true) - spansToAdd.add(pendingSpan) - } - - /** - * Adds the final version of a span to the list of pending spans to be inserted in the text, - * with valid type, [start], [end], and [flags]. - */ - private fun addPendingSpan( - span: T, start: Int = text.length, end: Int = text.length, flags: Int - ) { - spansToAdd.add(PendingSpan(span, start, end, flags, false)) - } - - /** - * Replaces a [placeholder] span in the list of pending spans with its final version, including - * the real type, the [start] and [end] indexes and the real [flags] to apply. - */ - private fun replacePlaceholderWithPendingSpan( - placeholder: Any, - span: Any = placeholder, - start: Int = text.length, - end: Int = text.length, - flags: Int - ) { - val index = spansToAdd.indexOfFirst { it.span === placeholder } - if (index >= 0) { - spansToAdd[index] = PendingSpan(span, start, end, flags, false) + private fun SpannableStringBuilder.addLeadingLineBreakForBlockNode(element: Element) { + if (element.isBlock && element.previousElementSibling() != null) { + append('\n') } } // endregion - /** - * Looks for the last span of the type [T] in the list of pending spans - * in the range ([from], [to]). - */ - private inline fun getLastPending( - from: Int = 0, to: Int = text.length - ): PendingSpan? { - return getLastPending(T::class.java, from, to) - } - - /** - * Looks for the last span of the [type] in the list of pending spans - * in the range ([from], [to]). - */ - @Suppress("UNCHECKED_CAST") - private fun getLastPending( - type: Class, from: Int = 0, to: Int = text.length - ): PendingSpan? { - return spansToAdd.findLast { - type.isInstance(it.span) && from <= it.start && to >= it.end - } as? PendingSpan - } - private fun Int.dpToPx(): Float { return resourcesHelper.dpToPx(this) } @@ -570,12 +329,6 @@ internal class HtmlToSpansParser( removeSpan(it) } - fun Editable.removePlaceholderSpans() = spans.flatMap { _ -> - getSpans(0, length).toList() - }.forEach { - removeSpan(it) - } - fun Spanned.assertOnlyAllowedSpans() { val textSpans = getSpans(0, length, Any::class.java) assert(textSpans.all { spans.contains(it.javaClass) }) { From 219dc937cd25f08fb3d15ad31fd148383232ca9a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jorge=20Mart=C3=ADn?= Date: Wed, 17 Jul 2024 09:39:11 +0200 Subject: [PATCH 2/3] Add `LoggingConfig` option to enable/disable logs. Add logs for unknown tag found while parsing. --- .../java/io/element/android/wysiwyg/EditorTextWatcher.kt | 9 ++++----- .../element/android/wysiwyg/utils/HtmlToSpansParser.kt | 4 ++++ .../io/element/android/wysiwyg/utils/LoggingConfig.kt | 7 +++++++ 3 files changed, 15 insertions(+), 5 deletions(-) create mode 100644 platforms/android/library/src/main/java/io/element/android/wysiwyg/utils/LoggingConfig.kt diff --git a/platforms/android/library/src/main/java/io/element/android/wysiwyg/EditorTextWatcher.kt b/platforms/android/library/src/main/java/io/element/android/wysiwyg/EditorTextWatcher.kt index d37edf734..00d90c43a 100644 --- a/platforms/android/library/src/main/java/io/element/android/wysiwyg/EditorTextWatcher.kt +++ b/platforms/android/library/src/main/java/io/element/android/wysiwyg/EditorTextWatcher.kt @@ -2,6 +2,7 @@ package io.element.android.wysiwyg import android.text.Editable import android.text.TextWatcher +import io.element.android.wysiwyg.utils.LoggingConfig import timber.log.Timber import java.util.concurrent.atomic.AtomicBoolean @@ -14,8 +15,6 @@ internal class EditorTextWatcher: TextWatcher { val isInEditorChange get() = updateIsFromEditor.get() - var enableDebugLogs = false - private var beforeText: CharSequence? = null /** @@ -53,7 +52,7 @@ internal class EditorTextWatcher: TextWatcher { } override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) { - if (enableDebugLogs) { + if (LoggingConfig.enableDebugLogs) { Timber.v("beforeTextChanged | text: \"$s\", start: $start, count: $count, after: $after") } if (!updateIsFromEditor.get()) { @@ -62,7 +61,7 @@ internal class EditorTextWatcher: TextWatcher { } override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) { - if (enableDebugLogs) { + if (LoggingConfig.enableDebugLogs) { Timber.v("onTextChanged | text: \"$s\", start: $start, before: $before, count: $count") } if (!updateIsFromEditor.get()) { @@ -72,7 +71,7 @@ internal class EditorTextWatcher: TextWatcher { } override fun afterTextChanged(s: Editable?) { - if (enableDebugLogs) { + if (LoggingConfig.enableDebugLogs) { Timber.v("afterTextChanged") } if (!updateIsFromEditor.get()) { diff --git a/platforms/android/library/src/main/java/io/element/android/wysiwyg/utils/HtmlToSpansParser.kt b/platforms/android/library/src/main/java/io/element/android/wysiwyg/utils/HtmlToSpansParser.kt index c06b291de..5ee749ea3 100644 --- a/platforms/android/library/src/main/java/io/element/android/wysiwyg/utils/HtmlToSpansParser.kt +++ b/platforms/android/library/src/main/java/io/element/android/wysiwyg/utils/HtmlToSpansParser.kt @@ -27,6 +27,7 @@ import io.element.android.wysiwyg.view.spans.UnorderedListSpan import org.jsoup.Jsoup import org.jsoup.nodes.Element import org.jsoup.nodes.TextNode +import timber.log.Timber import kotlin.math.roundToInt /** @@ -80,6 +81,9 @@ internal class HtmlToSpansParser( "blockquote" -> parseQuote(element) "p" -> parseParagraph(element) "br" -> parseLineBreak(element) + else -> if (LoggingConfig.enableDebugLogs) { + Timber.d("Unsupported tag: ${element.tagName()}") + } } } diff --git a/platforms/android/library/src/main/java/io/element/android/wysiwyg/utils/LoggingConfig.kt b/platforms/android/library/src/main/java/io/element/android/wysiwyg/utils/LoggingConfig.kt new file mode 100644 index 000000000..419e8cb91 --- /dev/null +++ b/platforms/android/library/src/main/java/io/element/android/wysiwyg/utils/LoggingConfig.kt @@ -0,0 +1,7 @@ +package io.element.android.wysiwyg.utils + +import io.element.android.wysiwyg.BuildConfig + +object LoggingConfig { + var enableDebugLogs = BuildConfig.DEBUG +} \ No newline at end of file From 5cfb891fb82f2455a46358a076d086717dc6b7cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jorge=20Mart=C3=ADn?= Date: Wed, 17 Jul 2024 09:39:56 +0200 Subject: [PATCH 3/3] Fix errors in tests related to whitespaces and span ordering --- ...InterceptInputConnectionIntegrationTest.kt | 6 +- .../wysiwyg/utils/HtmlToSpansParser.kt | 108 +++++++++++++++--- .../wysiwyg/utils/HtmlToSpansParserTest.kt | 43 +++---- 3 files changed, 117 insertions(+), 40 deletions(-) diff --git a/platforms/android/library/src/androidTest/java/io/element/android/wysiwyg/inputhandlers/InterceptInputConnectionIntegrationTest.kt b/platforms/android/library/src/androidTest/java/io/element/android/wysiwyg/inputhandlers/InterceptInputConnectionIntegrationTest.kt index ed2975570..afdc77bb5 100644 --- a/platforms/android/library/src/androidTest/java/io/element/android/wysiwyg/inputhandlers/InterceptInputConnectionIntegrationTest.kt +++ b/platforms/android/library/src/androidTest/java/io/element/android/wysiwyg/inputhandlers/InterceptInputConnectionIntegrationTest.kt @@ -361,7 +361,7 @@ class InterceptInputConnectionIntegrationTest { textView.text.dumpSpans().joinToString(",\n"), equalTo( """ hello: android.widget.TextView.ChangeWatcher (0-5) fl=#6553618, - hello: io.element.android.wysiwyg.view.spans.QuoteSpan (0-5) fl=#17, + hello: io.element.android.wysiwyg.view.spans.QuoteSpan (0-5) fl=#65553, hello: android.text.style.UnderlineSpan (0-5) fl=#289, hello: android.view.inputmethod.ComposingText (0-5) fl=#289, hello: android.text.method.TextKeyListener (0-5) fl=#18, @@ -408,8 +408,8 @@ class InterceptInputConnectionIntegrationTest { textView.text.dumpSpans(), equalTo( listOf( "Test\n$NBSP: android.widget.TextView.ChangeWatcher (0-6) fl=#6553618", + "Test\n$NBSP: io.element.android.wysiwyg.view.spans.QuoteSpan (0-6) fl=#65553", "$NBSP: io.element.android.wysiwyg.view.spans.ExtraCharacterSpan (5-6) fl=#17", - "Test\n$NBSP: io.element.android.wysiwyg.view.spans.QuoteSpan (0-6) fl=#17", "Test\n$NBSP: android.text.method.TextKeyListener (0-6) fl=#18", "Test\n$NBSP: android.widget.Editor.SpanController (0-6) fl=#18", ": android.text.Selection.START (5-5) fl=#546", @@ -426,7 +426,7 @@ class InterceptInputConnectionIntegrationTest { textView.text.dumpSpans(), equalTo( listOf( "Test\n$NBSP: android.widget.TextView.ChangeWatcher (0-6) fl=#6553618", - "Test: io.element.android.wysiwyg.view.spans.QuoteSpan (0-4) fl=#17", + "Test: io.element.android.wysiwyg.view.spans.QuoteSpan (0-4) fl=#65553", "$NBSP: io.element.android.wysiwyg.view.spans.ExtraCharacterSpan (5-6) fl=#17", "Test\n$NBSP: android.text.method.TextKeyListener (0-6) fl=#18", "Test\n$NBSP: android.widget.Editor.SpanController (0-6) fl=#18", diff --git a/platforms/android/library/src/main/java/io/element/android/wysiwyg/utils/HtmlToSpansParser.kt b/platforms/android/library/src/main/java/io/element/android/wysiwyg/utils/HtmlToSpansParser.kt index 5ee749ea3..ecff7bf9e 100644 --- a/platforms/android/library/src/main/java/io/element/android/wysiwyg/utils/HtmlToSpansParser.kt +++ b/platforms/android/library/src/main/java/io/element/android/wysiwyg/utils/HtmlToSpansParser.kt @@ -25,8 +25,12 @@ import io.element.android.wysiwyg.view.spans.PlainAtRoomMentionDisplaySpan import io.element.android.wysiwyg.view.spans.QuoteSpan import io.element.android.wysiwyg.view.spans.UnorderedListSpan import org.jsoup.Jsoup +import org.jsoup.internal.StringUtil +import org.jsoup.nodes.Document.OutputSettings import org.jsoup.nodes.Element +import org.jsoup.nodes.Node import org.jsoup.nodes.TextNode +import org.jsoup.safety.Safelist import timber.log.Timber import kotlin.math.roundToInt @@ -44,11 +48,20 @@ internal class HtmlToSpansParser( private val mentionDisplayHandler: MentionDisplayHandler?, private val isMention: ((text: String, url: String) -> Boolean)? = null, ) { + private val safeList = Safelist() + .addTags( + "a", "b", "strong", "i", "em", "u", "del", "code", "ul", "ol", "li", "pre", + "blockquote", "p", "br" + ) + .addAttributes("a", "href", "data-mention-type", "contenteditable") + /** * Convert the HTML string into a [Spanned] text. */ fun convert(): Spanned { - val dom = Jsoup.parse(html) + val outputSettings = OutputSettings().prettyPrint(false).indentAmount(0) + val cleanHtml = Jsoup.clean(html, "", safeList, outputSettings) + val dom = Jsoup.parse(cleanHtml) val text = buildSpannedString { val body = dom.body() parseChildren(body) @@ -58,11 +71,11 @@ internal class HtmlToSpansParser( return text } - private fun SpannableStringBuilder.parseChildren(element: Element) { + private fun SpannableStringBuilder.parseChildren(element: Element, parseTextNodes: Boolean = true) { for (child in element.childNodes()) { when (child) { is Element -> parseElement(child) - is TextNode -> parseTextNode(child) + is TextNode -> if (parseTextNodes) parseTextNode(child) } } } @@ -74,8 +87,9 @@ internal class HtmlToSpansParser( "i", "em" -> parseInlineFormatting(element, InlineFormat.Italic) "u" -> parseInlineFormatting(element, InlineFormat.Underline) "del" -> parseInlineFormatting(element, InlineFormat.StrikeThrough) + // Note we're using a different method for inline code "code" -> parseInlineCode(element) - "ul", "ol" -> parseChildren(element) + "ul", "ol" -> parseList(element) "li" -> parseListItem(element) "pre" -> parseCodeBlock(element) "blockquote" -> parseQuote(element) @@ -89,8 +103,22 @@ internal class HtmlToSpansParser( // region: Handle parsing of tags into spans + private fun SpannableStringBuilder.parseList(element: Element) { + addLeadingLineBreakForBlockNode(element) + parseChildren(element, parseTextNodes = false) + } + private fun SpannableStringBuilder.parseTextNode(child: TextNode) { - val text = child.wholeText + val isPreformattedText = child.anyAncestor { it.nameIs("pre") } + val text = if (isPreformattedText) { + child.wholeText + } else { + if (child.isBlank) { + child.normalisedWhitespace(stripLeading = true) + } else { + child.normalisedWhitespace(stripLeading = false) + } + } if (text.isEmpty()) return val previousSibling = child.previousSibling() as? Element @@ -106,6 +134,7 @@ internal class HtmlToSpansParser( InlineFormat.Italic -> StyleSpan(Typeface.ITALIC) InlineFormat.Underline -> UnderlineSpan() InlineFormat.StrikeThrough -> StrikethroughSpan() + // This is handled in parseInlineCode instead InlineFormat.InlineCode -> return } inSpans(span) { @@ -130,19 +159,20 @@ internal class HtmlToSpansParser( private fun SpannableStringBuilder.parseQuote(element: Element) { addLeadingLineBreakForBlockNode(element) val start = this.length - inSpans( + inSpansWithFlags( QuoteSpan( // TODO provide these values from the style config indicatorColor = 0xC0A0A0A0.toInt(), indicatorWidth = 4.dpToPx().toInt(), indicatorPadding = 6.dpToPx().toInt(), margin = 10.dpToPx().toInt(), - ) + ), + // Used to blockquote always wraps any internal block element (list, code block, etc.) + flags = Spanned.SPAN_INCLUSIVE_EXCLUSIVE or (1 shl Spanned.SPAN_PRIORITY_SHIFT) ) { parseChildren(element) + handleNbspInBlock(element, start, length) } - - handleNbspInBlock(element, start, length) } private fun SpannableStringBuilder.parseCodeBlock(element: Element) { @@ -156,9 +186,9 @@ internal class HtmlToSpansParser( ) ) { append(element.wholeText()) + handleNbspInBlock(element, start, length) } - handleNbspInBlock(element, start, length) // Handle NBSPs for new lines inside the preformatted text for (i in start + 1 until length) { if (this[i] == NBSP) { @@ -180,12 +210,13 @@ internal class HtmlToSpansParser( val order = (element.parent()?.select("li")?.indexOf(element) ?: 0) + 1 OrderedListSpan(typeface, textSize, order, gapWidth) } - else -> return } addLeadingLineBreakForBlockNode(element) + val start = this.length inSpans(span) { parseChildren(element) + handleNbspInBlock(element, start, length) } } @@ -251,7 +282,7 @@ internal class HtmlToSpansParser( private fun SpannableStringBuilder.handleNbspInBlock(element: Element, start: Int, end: Int) { if (!element.isBlock) return - if (element.childNodes().isEmpty()) { + if (element.childNodes().isEmpty() && this.isNotEmpty()) { this.append(NBSP) setSpan(ExtraCharacterSpan(), end - 1, end, Spanned.SPAN_INCLUSIVE_EXCLUSIVE) } else if (end - start == 1 && this.getOrNull(start) in listOf(' ', NBSP)) { @@ -260,7 +291,7 @@ internal class HtmlToSpansParser( } private fun SpannableStringBuilder.addLeadingLineBreakForBlockNode(element: Element) { - if (element.isBlock && element.previousElementSibling() != null) { + if (element.isBlock && element.previousElementSibling()?.takeIf { it.tagName() != "br" } != null) { append('\n') } } @@ -291,6 +322,57 @@ internal class HtmlToSpansParser( } } + private fun Node.anyAncestor(block: (Node) -> Boolean): Boolean { + var parent = parent() + while (parent != null) { + if (block(parent)) return true + parent = parent.parent() + } + return false + } + + private fun TextNode.normalisedWhitespace(stripLeading: Boolean): String { + var lastWasWhite = false + var reachedNonWhite = false + val text = wholeText + // Special case for when there's a single space + if (stripLeading && wholeText == " ") return wholeText + val result = StringUtil.borrowBuilder() + var i = 0 + while (i < wholeText.length) { + val c = text.codePointAt(i) + if (StringUtil.isActuallyWhitespace(c)) { + if (c == NBSP.code) { + result.appendCodePoint(c) + } else if ((stripLeading && !reachedNonWhite) || lastWasWhite) { + i += Character.charCount(c) + continue + } else { + result.append(' ') + } + lastWasWhite = true + } else { + result.appendCodePoint(c) + reachedNonWhite = true + lastWasWhite = false + } + i += Character.charCount(c) + } + return StringUtil.releaseBuilder(result) + } + + private inline fun SpannableStringBuilder.inSpansWithFlags( + vararg spans: Any, + flags: Int, + block: SpannableStringBuilder.() -> Unit + ) { + val from = length + block() + val to = length + for (span in spans) { + setSpan(span, from, to, flags) + } + } companion object FormattingSpans { /** diff --git a/platforms/android/library/src/test/kotlin/io/element/android/wysiwyg/utils/HtmlToSpansParserTest.kt b/platforms/android/library/src/test/kotlin/io/element/android/wysiwyg/utils/HtmlToSpansParserTest.kt index 8e3d471a8..4aa5ad015 100644 --- a/platforms/android/library/src/test/kotlin/io/element/android/wysiwyg/utils/HtmlToSpansParserTest.kt +++ b/platforms/android/library/src/test/kotlin/io/element/android/wysiwyg/utils/HtmlToSpansParserTest.kt @@ -30,13 +30,13 @@ class HtmlToSpansParserTest { assertThat( spanned.dumpSpans(), equalTo( listOf( - "bold: android.text.style.StyleSpan (0-4) fl=#33", - "italic: android.text.style.StyleSpan (4-10) fl=#33", - "underline: android.text.style.UnderlineSpan (10-19) fl=#33", - "strong: android.text.style.StyleSpan (19-25) fl=#33", - "emphasis: android.text.style.StyleSpan (25-33) fl=#33", - "strikethrough: android.text.style.StrikethroughSpan (33-46) fl=#33", - "code: io.element.android.wysiwyg.view.spans.InlineCodeSpan (46-50) fl=#33", + "bold: android.text.style.StyleSpan (0-4) fl=#17", + "italic: android.text.style.StyleSpan (4-10) fl=#17", + "underline: android.text.style.UnderlineSpan (10-19) fl=#17", + "strong: android.text.style.StyleSpan (19-25) fl=#17", + "emphasis: android.text.style.StyleSpan (25-33) fl=#17", + "strikethrough: android.text.style.StrikethroughSpan (33-46) fl=#17", + "code: io.element.android.wysiwyg.view.spans.InlineCodeSpan (46-50) fl=#17", ) ) ) @@ -56,14 +56,13 @@ class HtmlToSpansParserTest { """.trimIndent() val spanned = convertHtml(html) - assertThat( spanned.dumpSpans().joinToString(",\n"), equalTo( """ - ordered1: io.element.android.wysiwyg.view.spans.OrderedListSpan (0-8) fl=#34, - ordered2: io.element.android.wysiwyg.view.spans.OrderedListSpan (9-17) fl=#34, - bullet1: io.element.android.wysiwyg.view.spans.UnorderedListSpan (18-25) fl=#34, - bullet2: io.element.android.wysiwyg.view.spans.UnorderedListSpan (26-33) fl=#34 + ordered1: io.element.android.wysiwyg.view.spans.OrderedListSpan (0-8) fl=#17, + ordered2: io.element.android.wysiwyg.view.spans.OrderedListSpan (9-17) fl=#17, + bullet1: io.element.android.wysiwyg.view.spans.UnorderedListSpan (18-25) fl=#17, + bullet2: io.element.android.wysiwyg.view.spans.UnorderedListSpan (26-33) fl=#17 """.trimIndent() ) ) @@ -104,13 +103,12 @@ class HtmlToSpansParserTest { assertThat( spanned.dumpSpans(), equalTo( listOf( - "$NBSP: io.element.android.wysiwyg.view.spans.ExtraCharacterSpan (0-1) fl=#17", - "$NBSP: io.element.android.wysiwyg.view.spans.ExtraCharacterSpan (2-3) fl=#17" + "\n: io.element.android.wysiwyg.view.spans.ExtraCharacterSpan (0-1) fl=#17", ) ) ) assertThat( - spanned.toString(), equalTo("$NBSP\n$NBSP") + spanned.toString(), equalTo("\n$NBSP") ) } @@ -119,9 +117,7 @@ class HtmlToSpansParserTest { val html = "

    Hello


    world

    " val spanned = convertHtml(html) assertThat( - spanned.dumpSpans(), equalTo( - emptyList() - ) + spanned.dumpSpans(), equalTo(emptyList()) ) assertThat( spanned.toString(), equalTo("Hello\n\nworld") @@ -131,9 +127,8 @@ class HtmlToSpansParserTest { @Test fun testMentionDisplayWithCustomMentionDisplayHandler() { val html = """ - link - jonny - @room + link$NBSP + jonny$NBSP@room """.trimIndent() val spanned = convertHtml(html, mentionDisplayHandler = object : MentionDisplayHandler { override fun resolveAtRoomMentionDisplay(): TextDisplay = @@ -145,15 +140,15 @@ class HtmlToSpansParserTest { assertThat( spanned.dumpSpans(), equalTo( listOf( - "link: io.element.android.wysiwyg.view.spans.LinkSpan (0-4) fl=#33", - "jonny: io.element.android.wysiwyg.view.spans.PillSpan (5-10) fl=#33", + "link: io.element.android.wysiwyg.view.spans.LinkSpan (0-4) fl=#17", "onny: io.element.android.wysiwyg.view.spans.ExtraCharacterSpan (6-10) fl=#33", + "jonny: io.element.android.wysiwyg.view.spans.PillSpan (5-10) fl=#17", "@room: io.element.android.wysiwyg.view.spans.PillSpan (11-16) fl=#33", ) ) ) assertThat( - spanned.toString(), equalTo("link\njonny\n@room") + spanned.toString().replace(NBSP, ' '), equalTo("link jonny @room") ) }