From 4777d9725bad3c1619ba8a002fbe1b0adffc5b01 Mon Sep 17 00:00:00 2001 From: Jonas Kalderstam Date: Wed, 15 May 2024 10:22:42 +0200 Subject: [PATCH] Rewrote reader layout engine. Adds real table support. --- .github/workflows/branch_apk.yml | 2 +- .../feeder/model/html/HtmlLinearizer.kt | 944 +++++++++++ .../feeder/model/html/LinearStuff.kt | 340 ++++ .../feeder/model/html/LinearTextAnnotation.kt | 54 + .../feeder/model/html/LinearTextBuilder.kt | 184 +++ .../feeder/ui/ActivityExceptionHandler.kt | 12 +- .../ui/compose/feedarticle/ArticleScreen.kt | 80 +- .../feedarticle/FeedArticleViewModel.kt | 69 +- .../ui/compose/feedarticle/ReaderView.kt | 2 - .../ui/compose/html/LinearArticleContent.kt | 1445 +++++++++++++++++ .../feeder/ui/compose/layouts/Table.kt | 226 +++ .../feeder/ui/compose/text/EagerComposer.kt | 81 - .../feeder/ui/compose/text/HtmlComposer.kt | 60 - .../ui/compose/text/HtmlToComposable.kt | 1389 +--------------- .../ui/compose/text/LazyListComposer.kt | 85 - .../feeder/ui/compose/theme/Dimensions.kt | 45 +- .../feeder/ui/compose/theme/Typography.kt | 3 + .../ui/compose/utils/ComposeProviders.kt | 32 +- .../ui/compose/utils/ProvideScaledText.kt | 3 +- app/src/main/res/values/strings.xml | 1 + .../feeder/model/html/HtmlLinearizerTest.kt | 1393 ++++++++++++++++ .../compose/text/HtmlToComposableUnitTest.kt | 324 ---- 22 files changed, 4739 insertions(+), 2035 deletions(-) create mode 100644 app/src/main/java/com/nononsenseapps/feeder/model/html/HtmlLinearizer.kt create mode 100644 app/src/main/java/com/nononsenseapps/feeder/model/html/LinearStuff.kt create mode 100644 app/src/main/java/com/nononsenseapps/feeder/model/html/LinearTextAnnotation.kt create mode 100644 app/src/main/java/com/nononsenseapps/feeder/model/html/LinearTextBuilder.kt create mode 100644 app/src/main/java/com/nononsenseapps/feeder/ui/compose/html/LinearArticleContent.kt create mode 100644 app/src/main/java/com/nononsenseapps/feeder/ui/compose/layouts/Table.kt delete mode 100644 app/src/main/java/com/nononsenseapps/feeder/ui/compose/text/EagerComposer.kt delete mode 100644 app/src/main/java/com/nononsenseapps/feeder/ui/compose/text/LazyListComposer.kt create mode 100644 app/src/test/java/com/nononsenseapps/feeder/model/html/HtmlLinearizerTest.kt delete mode 100644 app/src/test/java/com/nononsenseapps/feeder/ui/compose/text/HtmlToComposableUnitTest.kt diff --git a/.github/workflows/branch_apk.yml b/.github/workflows/branch_apk.yml index b9d489ac0..4e0ae1342 100644 --- a/.github/workflows/branch_apk.yml +++ b/.github/workflows/branch_apk.yml @@ -3,7 +3,7 @@ name: Signed APKs on: push: branches: - - upgrades + - table-layout jobs: signed_apk: diff --git a/app/src/main/java/com/nononsenseapps/feeder/model/html/HtmlLinearizer.kt b/app/src/main/java/com/nononsenseapps/feeder/model/html/HtmlLinearizer.kt new file mode 100644 index 000000000..da1480052 --- /dev/null +++ b/app/src/main/java/com/nononsenseapps/feeder/model/html/HtmlLinearizer.kt @@ -0,0 +1,944 @@ +package com.nononsenseapps.feeder.model.html + +import android.util.Log +import com.nononsenseapps.feeder.ui.compose.text.ancestors +import com.nononsenseapps.feeder.ui.compose.text.stripHtml +import com.nononsenseapps.feeder.ui.text.getVideo +import com.nononsenseapps.feeder.util.asUTF8Sequence +import com.nononsenseapps.feeder.util.logDebug +import org.jsoup.Jsoup +import org.jsoup.helper.StringUtil +import org.jsoup.nodes.Element +import org.jsoup.nodes.Node +import org.jsoup.nodes.TextNode +import java.io.InputStream + +class HtmlLinearizer { + private var linearTextBuilder: LinearTextBuilder = LinearTextBuilder() + + fun linearize( + html: String, + baseUrl: String, + ) = html.byteInputStream().use { linearize(it, baseUrl) } + + fun linearize( + inputStream: InputStream, + baseUrl: String, + ): LinearArticle { + return LinearArticle( + elements = + try { + Jsoup.parse(inputStream, null, baseUrl) + ?.body() + ?.let { body -> + linearizeBody(body, baseUrl) + } + ?: emptyList() + } catch (e: Exception) { + Log.e(LOG_TAG, "htmlFormattingFailed", e) + emptyList() + }, + ) + } + + private fun linearizeBody( + body: Element, + baseUrl: String, + ): List { + return ListBuilderScope { + asElement(blockStyle = LinearTextBlockStyle.TEXT) { + linearizeChildren( + body.childNodes(), + blockStyle = it, + baseUrl = baseUrl, + ) + } + }.items + } + + private fun ListBuilderScope.linearizeChildren( + nodes: List, + baseUrl: String, + blockStyle: LinearTextBlockStyle, + ) { + var node = nodes.firstOrNull() + while (node != null) { + when (node) { + is TextNode -> { + if (blockStyle.shouldSoftWrap) { + node.appendCorrectlyNormalizedWhiteSpace( + linearTextBuilder, + stripLeading = linearTextBuilder.endsWithWhitespace, + ) + } else { + append(node.wholeText) + } + } + + is Element -> { + val element = node + + if (isHiddenByCSS(element)) { + // Element is not supposed to be shown because javascript and/or tracking + node = node.nextSibling() + continue + } + + when (element.tagName()) { + "p" -> { + // Readability4j inserts p-tags in divs for algorithmic purposes. + // They screw up formatting. + if (node.hasClass("readability-styled")) { + linearizeChildren( + element.childNodes(), + blockStyle = LinearTextBlockStyle.TEXT, + baseUrl = baseUrl, + ) + } else { + asElement(blockStyle) { + linearizeChildren( + element.childNodes(), + blockStyle = LinearTextBlockStyle.TEXT, + baseUrl = baseUrl, + ) + } + } + } + + "br" -> append('\n') + + "h1" -> { + asElement(blockStyle) { + withLinearTextAnnotation(LinearTextAnnotationH1) { + element.appendCorrectlyNormalizedWhiteSpaceRecursively( + linearTextBuilder, + stripLeading = linearTextBuilder.endsWithWhitespace, + ) + } + } + } + + "h2" -> { + asElement(blockStyle) { + withLinearTextAnnotation(LinearTextAnnotationH2) { + element.appendCorrectlyNormalizedWhiteSpaceRecursively( + linearTextBuilder, + stripLeading = linearTextBuilder.endsWithWhitespace, + ) + } + } + } + + "h3" -> { + asElement(blockStyle) { + withLinearTextAnnotation(LinearTextAnnotationH3) { + element.appendCorrectlyNormalizedWhiteSpaceRecursively( + linearTextBuilder, + stripLeading = linearTextBuilder.endsWithWhitespace, + ) + } + } + } + + "h4" -> { + asElement(blockStyle) { + withLinearTextAnnotation(LinearTextAnnotationH4) { + element.appendCorrectlyNormalizedWhiteSpaceRecursively( + linearTextBuilder, + stripLeading = linearTextBuilder.endsWithWhitespace, + ) + } + } + } + + "h5" -> { + asElement(blockStyle) { + withLinearTextAnnotation(LinearTextAnnotationH5) { + element.appendCorrectlyNormalizedWhiteSpaceRecursively( + linearTextBuilder, + stripLeading = linearTextBuilder.endsWithWhitespace, + ) + } + } + } + + "h6" -> { + asElement(blockStyle) { + withLinearTextAnnotation(LinearTextAnnotationH6) { + element.appendCorrectlyNormalizedWhiteSpaceRecursively( + linearTextBuilder, + stripLeading = linearTextBuilder.endsWithWhitespace, + ) + } + } + } + + "strong", "b" -> { + withLinearTextAnnotation(LinearTextAnnotationBold) { + linearizeChildren( + element.childNodes(), + blockStyle = blockStyle, + baseUrl = baseUrl, + ) + } + } + + "i", "em", "cite", "dfn" -> { + withLinearTextAnnotation(LinearTextAnnotationItalic) { + linearizeChildren( + element.childNodes(), + blockStyle = blockStyle, + baseUrl = baseUrl, + ) + } + } + + "span" -> { + val style = + element.attr("style") + .splitToSequence(";") + .map { + it.split(":", limit = 2) + } + .filter { it.size == 2 } + .associate { + it[0].trim() to it[1].trim() + } + + val maybeBold = + if (style["font-weight"] == "bold") { + LinearTextAnnotationBold + } else { + null + } + + val maybeItalic = + if (style["font-style"] in setOf("italic", "oblique")) { + LinearTextAnnotationItalic + } else { + null + } + + withLinearTextAnnotation(maybeBold) { + withLinearTextAnnotation(maybeItalic) { + linearizeChildren( + element.childNodes(), + blockStyle = blockStyle, + baseUrl = baseUrl, + ) + } + } + } + + "tt" -> { + withLinearTextAnnotation(LinearTextAnnotationMonospace) { + linearizeChildren( + element.childNodes(), + blockStyle = blockStyle, + baseUrl = baseUrl, + ) + } + } + + "u" -> { + withLinearTextAnnotation(LinearTextAnnotationUnderline) { + linearizeChildren( + element.childNodes(), + blockStyle = blockStyle, + baseUrl = baseUrl, + ) + } + } + + "s" -> { + withLinearTextAnnotation(LinearTextAnnotationStrikethrough) { + linearizeChildren( + element.childNodes(), + blockStyle = blockStyle, + baseUrl = baseUrl, + ) + } + } + + "sup" -> { + withLinearTextAnnotation(LinearTextAnnotationSuperscript) { + linearizeChildren( + element.childNodes(), + blockStyle = blockStyle, + baseUrl = baseUrl, + ) + } + } + + "sub" -> { + withLinearTextAnnotation(LinearTextAnnotationSubscript) { + linearizeChildren( + element.childNodes(), + blockStyle = blockStyle, + baseUrl = baseUrl, + ) + } + } + + "font" -> { + val face: String? = element.attr("face").ifBlank { null } + if (face != null) { + withLinearTextAnnotation(LinearTextAnnotationFont(face)) { + this@linearizeChildren.linearizeChildren( + element.childNodes(), + blockStyle = blockStyle, + baseUrl = baseUrl, + ) + } + } else { + linearizeChildren( + element.childNodes(), + blockStyle = blockStyle, + baseUrl = baseUrl, + ) + } + } + + "pre" -> { + asElement( + blockStyle = + if (element.selectFirst("code") != null) { + LinearTextBlockStyle.CODE_BLOCK + } else { + LinearTextBlockStyle.PRE_FORMATTED + }, + ) { + linearizeChildren( + element.childNodes(), + blockStyle = it, + baseUrl = baseUrl, + ) + } + } + + "code" -> { + withLinearTextAnnotation(LinearTextAnnotationCode) { + linearizeChildren( + element.childNodes(), + blockStyle = blockStyle, + baseUrl = baseUrl, + ) + } + } + +// "q" -> { + // TODO +// The tag defines a short quotation. +// Browsers normally insert quotation marks around the quotation. +// } + + "blockquote" -> { + finalizeAndAddCurrentElement(blockStyle) + add( + LinearBlockQuote( + cite = element.attr("cite").ifBlank { null }, + content = + ListBuilderScope { + asElement(blockStyle = LinearTextBlockStyle.TEXT) { + linearizeChildren( + element.childNodes(), + blockStyle = LinearTextBlockStyle.TEXT, + baseUrl = baseUrl, + ) + } + }.items, + ), + ) + } + + "a" -> { + withLinearTextAnnotation(LinearTextAnnotationLink(element.attr("abs:href"))) { + linearizeChildren( + element.childNodes(), + blockStyle = blockStyle, + baseUrl = baseUrl, + ) + } + } + + "figcaption" -> { + // If not inside figure then FullTextParsing just failed + if (element.parent()?.tagName() == "figure") { + linearizeChildren( + nodes = element.childNodes(), + blockStyle = blockStyle, + baseUrl = baseUrl, + ) + } + } + + "figure" -> { + finalizeAndAddCurrentElement(blockStyle) + + // Wordpress likes nested figures to get images side by side + val imageCandidates = + element.descendantImageCandidates(baseUrl = baseUrl) + // Arstechnica has its own ideas about how to structure things + ?: element.ancestorImageCandidates(baseUrl = baseUrl) + + if (imageCandidates != null) { + val link = linearTextBuilder.findClosestLink()?.takeIf { it.isNotBlank() } + + val caption: LinearText? = + ListBuilderScope { + asElement(blockStyle = LinearTextBlockStyle.TEXT) { + linearizeChildren( + element.childNodes(), + blockStyle = it, + baseUrl = baseUrl, + ) + } + }.items.firstOrNull { + // Stuffing non-text inside a caption is not supported + it is LinearText && it.text.isNotBlank() + } as? LinearText + + add( + LinearImage( + sources = imageCandidates, + caption = caption, + link = link, + ), + ) + } + } + + "img" -> { + finalizeAndAddCurrentElement(blockStyle) + + getImageSource(baseUrl, element).let { candidates -> + if (candidates.isNotEmpty()) { + val captionText: String? = + stripHtml(element.attr("alt")) + .takeIf { it.isNotBlank() } + add( + LinearImage( + sources = candidates, + // Parse a LinearText with annotations from element.attr("alt") + caption = + captionText?.let { + LinearText( + text = it, + annotations = emptyList(), + blockStyle = LinearTextBlockStyle.TEXT, + ) + }, + link = linearTextBuilder.findClosestLink()?.takeIf { it.isNotBlank() }, + ), + ) + } + } + } + + "ul", "ol" -> { + finalizeAndAddCurrentElement(blockStyle) + + val list = + LinearList.build(ordered = element.tagName() == "ol") { + element.children() + .filter { it.tagName() == "li" } + .forEach { listItem -> + val item = + LinearListItem { + asElement(blockStyle) { + linearizeChildren( + listItem.childNodes(), + blockStyle = it, + baseUrl = baseUrl, + ) + } + } + + if (item.isNotEmpty()) { + add(item) + } + } + } + + if (list.isNotEmpty()) { + add(list) + } + } + + "td", "th" -> { + // If we end up here, that means the table has been optimized out. Treat as a div. + asElement(blockStyle) { + linearizeChildren( + element.childNodes(), + blockStyle = blockStyle, + baseUrl = baseUrl, + ) + } + } + + "table" -> { + finalizeAndAddCurrentElement(blockStyle) + + val rowSequence = + sequence { + element.children() + .asSequence() + .filter { child -> + child.tagName() in setOf("thead", "tbody", "tfoot", "tr") + } + .sortedBy { child -> + when (child.tagName()) { + "thead" -> 0 + "tbody" -> 1 + "tr" -> 2 + "tfoot" -> 3 + else -> 99 + } + } + .forEach { child -> + if (child.tagName() == "tr") { + yield(child) + } else { + yieldAll(child.children().filter { it.tagName() == "tr" }) + } + } + } + + val colCount = + rowSequence + .map { row -> + row.children().count { it.tagName() == "td" || it.tagName() == "th" } + } + .maxOrNull() + ?: 0 + + // If there is only a single row, or a single column, then don't bother with a table + if (colCount == 1 || rowSequence.count() == 1) { + linearizeChildren( + element.childNodes(), + blockStyle = blockStyle, + baseUrl = baseUrl, + ) + } else { + add( + LinearTable.build { + rowSequence + .forEach { row -> + newRow() + + row.children() + .filter { it.tagName() == "td" || it.tagName() == "th" } + .forEach { cell -> + add( + LinearTableCellItem( + colSpan = cell.attr("colspan").toIntOrNull()?.coerceAtLeast(-1) ?: 1, + rowSpan = cell.attr("rowspan").toIntOrNull()?.coerceAtLeast(-1) ?: 1, + type = + if (cell.tagName() == "th") { + LinearTableCellItemType.HEADER + } else { + LinearTableCellItemType.DATA + }, + ) { + asElement(blockStyle = blockStyle) { + linearizeChildren( + cell.childNodes(), + blockStyle = it, + baseUrl = baseUrl, + ) + } + }, + ) + } + } + }, + ) + } + } + + "rt", "rp" -> { + // Ruby text elements. Not supported. + } + + "audio" -> { + val sources = + element.getElementsByTag("source").asSequence() + .mapNotNull { source -> + source.attr("abs:src").takeIf { it.isNotBlank() }?.let { src -> + LinearAudioSource( + uri = src, + mimeType = source.attr("type").ifBlank { null }, + ) + } + }.toList() + .takeIf { it.isNotEmpty() } + + if (sources != null) { + add(LinearAudio(sources)) + } + } + + "iframe" -> { + getVideo(element.attr("abs:src").ifBlank { null })?.let { video -> + add( + LinearVideo( + sources = + listOf( + LinearVideoSource( + uri = video.src, + link = video.link, + imageThumbnail = video.imageUrl, + widthPx = video.width, + heightPx = video.height, + mimeType = null, + ), + ), + ), + ) + } + } + + "video" -> { + val width = element.attr("width").toIntOrNull() + val height = element.attr("height").toIntOrNull() + val sources = + element.getElementsByTag("source").asSequence() + .mapNotNull { source -> + source.attr("abs:src").takeIf { it.isNotBlank() }?.let { src -> + LinearVideoSource( + uri = src, + link = src, + imageThumbnail = null, + mimeType = source.attr("type").ifBlank { null }, + widthPx = width, + heightPx = height, + ) + } + }.toList() + .takeIf { it.isNotEmpty() } + + if (sources != null) { + add(LinearVideo(sources)) + } + } + + else -> { + linearizeChildren( + nodes = element.childNodes(), + blockStyle = blockStyle, + baseUrl = baseUrl, + ) + } + } + } + } + + node = node.nextSibling() + } + } + + private fun append(c: String) { + linearTextBuilder.append(c) + } + + @Suppress("SameParameterValue") + private fun append(c: Char) { + linearTextBuilder.append(c) + } + + internal fun ListBuilderScope.finalizeAndAddCurrentElement(blockStyle: LinearTextBlockStyle) { + if (linearTextBuilder.isNotEmpty()) { + add(linearTextBuilder.toLinearText(blockStyle = blockStyle)) + linearTextBuilder.clearKeepingSpans() + } + } + + private inline fun ListBuilderScope.asElement( + blockStyle: LinearTextBlockStyle, + block: ListBuilderScope.(blockStyle: LinearTextBlockStyle) -> R, + ): R { + finalizeAndAddCurrentElement(blockStyle) + return this.block(blockStyle).also { + finalizeAndAddCurrentElement(blockStyle) + } + } + + private inline fun ListBuilderScope.withLinearTextAnnotation( + annotationData: LinearTextAnnotationData?, + block: ListBuilderScope.() -> R, + ): R { + // Nullable to handle span styles easier. If null, no annotation is added. + if (annotationData == null) { + return this.block() + } + + val i = linearTextBuilder.push(annotationData) + return try { + this.block() + } finally { + linearTextBuilder.pop(i) + } + } + + private fun isHiddenByCSS(element: Element): Boolean { + val style = element.attr("style") + return style.contains("display:") && style.contains("none") + } + + private fun getImageSource( + baseUrl: String, + element: Element, + ): List { + val absSrc: String = element.attr("abs:src") + val dataImgUrl: String = element.attr("data-img-url").ifBlank { element.attr("data-src") } + val srcSet: String = element.attr("srcset").ifBlank { element.attr("data-responsive") } + // Can be set on divs - see ArsTechnica + val backgroundImage = + element.attr("style") + .ifBlank { null } + ?.splitToSequence(";") + ?.map { it.trim() } + ?.map { it.split(":", limit = 2) } + ?.mapNotNull { kv -> + if (kv.size != 2) { + null + } else { + val (key, value) = kv + if (key.trim() == "background-image") { + value.trim().removePrefix("url('").removeSuffix("')") + } else { + null + } + } + } + ?.firstOrNull() + ?: "" + + val result = mutableListOf() + + try { + srcSet.splitToSequence(", ") + .map { it.trim() } + .map { it.split(spaceRegex).take(2).map { x -> x.trim() } } + .forEach { candidate -> + if (candidate.first().isBlank()) { + return@forEach + } + if (candidate.size == 1) { + result.add( + LinearImageSource( + imgUri = StringUtil.resolve(baseUrl, candidate.first()), + pixelDensity = null, + heightPx = null, + widthPx = null, + screenWidth = null, + ), + ) + } else { + val descriptor = candidate.last() + when { + descriptor.endsWith("w", ignoreCase = true) -> { + val width = descriptor.substringBefore("w").toFloat() + if (width < 0f) { + return@forEach + } + + result.add( + LinearImageSource( + imgUri = StringUtil.resolve(baseUrl, candidate.first()), + pixelDensity = null, + heightPx = null, + widthPx = null, + screenWidth = width.toInt(), + ), + ) + } + + descriptor.endsWith("x", ignoreCase = true) -> { + val density = descriptor.substringBefore("x").toFloat() + + if (density < 0f) { + return@forEach + } + + result.add( + LinearImageSource( + imgUri = StringUtil.resolve(baseUrl, candidate.first()), + pixelDensity = density, + heightPx = null, + widthPx = null, + screenWidth = null, + ), + ) + } + } + } + } + + val width = element.attr("width").toIntOrNull() + val height = element.attr("height").toIntOrNull() + + dataImgUrl.takeIf { it.isNotBlank() }?.let { + val url = StringUtil.resolve(baseUrl, it) + if (width != null && height != null) { + result.add( + LinearImageSource( + imgUri = url, + pixelDensity = null, + screenWidth = null, + heightPx = height, + widthPx = width, + ), + ) + } else { + result.add( + LinearImageSource( + imgUri = url, + pixelDensity = null, + heightPx = null, + widthPx = null, + screenWidth = null, + ), + ) + } + } + + absSrc.takeIf { it.isNotBlank() }?.let { + val url = StringUtil.resolve(baseUrl, it) + if (width != null && height != null) { + result.add( + LinearImageSource( + imgUri = url, + pixelDensity = null, + screenWidth = null, + heightPx = height, + widthPx = width, + ), + ) + } else { + result.add( + LinearImageSource( + imgUri = url, + pixelDensity = null, + screenWidth = null, + heightPx = null, + widthPx = null, + ), + ) + } + } + + backgroundImage.takeIf { it.isNotBlank() }?.let { + val url = StringUtil.resolve(baseUrl, it) + result.add( + LinearImageSource( + imgUri = url, + pixelDensity = null, + screenWidth = null, + heightPx = null, + widthPx = null, + ), + ) + } + } catch (e: Throwable) { + logDebug(LOG_TAG, "Failed to parse image source", e) + } + return result + } + + private fun Element.descendantImageCandidates(baseUrl: String): List? { + // Arstechnica is weird and has images inside divs inside figures + return sequence { + yieldAll(getElementsByTag("img")) + yieldAll(getElementsByClass("image")) + } + .flatMap { getImageSource(baseUrl, it) } + .distinctBy { it.imgUri } + .toList() + .takeIf { it.isNotEmpty() } + } + + private fun Element.ancestorImageCandidates(baseUrl: String): List? { + // Arstechnica is weird and places image details in list items which themselves contain the figure + return ancestors { + it.hasAttr("data-src") || it.hasAttr("data-responsive") + } + .flatMap { getImageSource(baseUrl, it) } + .distinctBy { it.imgUri } + .toList() + .takeIf { it.isNotEmpty() } + } + + companion object { + private const val LOG_TAG = "FEEDERHtmlLinearizer" + private val spaceRegex = Regex("\\s+") + } +} + +/** + * Can't use JSoup's text() method because that strips invisible characters + * such as ZWNJ which are crucial for several languages. + */ +fun TextNode.appendCorrectlyNormalizedWhiteSpace( + builder: LinearTextBuilder, + stripLeading: Boolean, +) { + wholeText.asUTF8Sequence() + .dropWhile { + stripLeading && isCollapsableWhiteSpace(it) + } + .fold(false) { lastWasWhite, char -> + if (isCollapsableWhiteSpace(char)) { + if (!lastWasWhite) { + builder.append(' ') + } + true + } else { + builder.append(char) + false + } + } +} + +fun Element.appendCorrectlyNormalizedWhiteSpaceRecursively( + builder: LinearTextBuilder, + stripLeading: Boolean, +) { + for (child in childNodes()) { + when (child) { + is TextNode -> child.appendCorrectlyNormalizedWhiteSpace(builder, stripLeading) + is Element -> + child.appendCorrectlyNormalizedWhiteSpaceRecursively( + builder, + stripLeading, + ) + } + } +} + +class ListBuilderScope(block: ListBuilderScope.() -> Unit) { + val items = mutableListOf() + + init { + block() + } + + fun add(item: T) { + items.add(item) + } +} + +private const val SPACE = ' ' +private const val TAB = '\t' +private const val LINE_FEED = '\n' +private const val CARRIAGE_RETURN = '\r' + +// 12 is form feed which as no escape in kotlin +private const val FORM_FEED = 12.toChar() + +// 160 is   (non-breaking space). Not in the spec but expected. +private const val NON_BREAKING_SPACE = 160.toChar() + +private fun isCollapsableWhiteSpace(c: String) = c.firstOrNull()?.let { isCollapsableWhiteSpace(it) } ?: false + +private fun isCollapsableWhiteSpace(c: Char) = c == SPACE || c == TAB || c == LINE_FEED || c == CARRIAGE_RETURN || c == FORM_FEED || c == NON_BREAKING_SPACE diff --git a/app/src/main/java/com/nononsenseapps/feeder/model/html/LinearStuff.kt b/app/src/main/java/com/nononsenseapps/feeder/model/html/LinearStuff.kt new file mode 100644 index 000000000..c550571de --- /dev/null +++ b/app/src/main/java/com/nononsenseapps/feeder/model/html/LinearStuff.kt @@ -0,0 +1,340 @@ +package com.nononsenseapps.feeder.model.html + +import androidx.collection.ArrayMap + +data class LinearArticle( + val elements: List, +) + +/** + * A linear element can contain other linear elements + */ +sealed interface LinearElement + +/** + * Represents a list of items, ordered or unordered + */ +data class LinearList( + val ordered: Boolean, + val items: List, +) : LinearElement { + fun isEmpty(): Boolean { + return items.isEmpty() + } + + fun isNotEmpty(): Boolean { + return items.isNotEmpty() + } + + class Builder(private val ordered: Boolean) { + private val items: MutableList = mutableListOf() + + fun add(item: LinearListItem) { + items.add(item) + } + + fun build(): LinearList { + return LinearList(ordered, items) + } + } + + companion object { + fun build( + ordered: Boolean, + block: Builder.() -> Unit, + ): LinearList { + return Builder(ordered).apply(block).build() + } + } +} + +/** + * Represents a single item in a list + */ +data class LinearListItem( + val content: List, +) { + constructor(block: ListBuilderScope.() -> Unit) : this(content = ListBuilderScope(block).items) + + constructor(vararg elements: LinearElement) : this(content = elements.toList()) + + fun isEmpty(): Boolean { + return content.isEmpty() + } + + fun isNotEmpty(): Boolean { + return content.isNotEmpty() + } + + class Builder { + private val content: MutableList = mutableListOf() + + fun add(element: LinearElement) { + content.add(element) + } + + fun build(): LinearListItem { + return LinearListItem(content) + } + } + + companion object { + fun build(block: Builder.() -> Unit): LinearListItem { + return Builder().apply(block).build() + } + } +} + +/** + * Represents a table + */ +data class LinearTable( + val rowCount: Int, + val colCount: Int, + private val cellsReal: ArrayMap, +) : LinearElement { + val cells: Map + get() = cellsReal + + constructor( + rowCount: Int, + colCount: Int, + cells: List, + ) : this( + rowCount, + colCount, + ArrayMap().apply { + cells.forEachIndexed { index, item -> + put(Coordinate(row = index / colCount, col = index % colCount), item) + } + }, + ) + + fun cellAt( + row: Int, + col: Int, + ): LinearTableCellItem? { + return cells[Coordinate(row = row, col = col)] + } + + class Builder { + private val cells: ArrayMap = ArrayMap() + private var rowCount: Int = 0 + private var colCount: Int = 0 + private var currentRowColCount = 0 + private var currentRow = 0 + + fun add(element: LinearTableCellItem) { + check(rowCount > 0) { "Must add a row before adding cells" } + + // First find the first empty cell in this row + var cellCoord = Coordinate(row = currentRow, col = currentRowColCount) + while (cells[cellCoord] != null) { + currentRowColCount++ + cellCoord = cellCoord.copy(col = currentRowColCount) + } + + currentRowColCount += element.colSpan + if (currentRowColCount > colCount) { + colCount = currentRowColCount + } + + cells[cellCoord] = element + + // Insert filler elements for spanned cells + for (r in 0 until element.rowSpan) { + for (c in 0 until element.colSpan) { + // Skip first since this is the cell itself + if (r == 0 && c == 0) { + continue + } + + val fillerCoord = Coordinate(row = currentRow + r, col = currentRowColCount - element.colSpan + c) + check(cells[fillerCoord] == null) { "Cell at filler $fillerCoord already exists" } + cells[fillerCoord] = LinearTableCellItem.filler + } + } + } + + fun newRow() { + if (rowCount > 0) { + currentRow++ + } + rowCount++ + currentRowColCount = 0 + } + + fun build(): LinearTable { + return LinearTable(rowCount, colCount, cells) + } + } + + companion object { + fun build(block: Builder.() -> Unit): LinearTable { + return Builder().apply(block).build() + } + } +} + +data class Coordinate( + val row: Int, + val col: Int, +) + +/** + * Represents a single cell in a table + */ +data class LinearTableCellItem( + val type: LinearTableCellItemType, + val colSpan: Int, + val rowSpan: Int, + val content: List, +) { + constructor( + colSpan: Int, + rowSpan: Int, + type: LinearTableCellItemType, + block: ListBuilderScope.() -> Unit, + ) : this(colSpan = colSpan, rowSpan = rowSpan, type = type, content = ListBuilderScope(block).items) + + val isFiller + get() = colSpan == filler.colSpan && rowSpan == filler.rowSpan + + class Builder( + private val colSpan: Int, + private val rowSpan: Int, + private val type: LinearTableCellItemType, + ) { + private val content: MutableList = mutableListOf() + + fun add(element: LinearElement) { + content.add(element) + } + + fun build(): LinearTableCellItem { + return LinearTableCellItem(colSpan = colSpan, rowSpan = rowSpan, type = type, content = content) + } + } + + companion object { + fun build( + colSpan: Int, + rowSpan: Int, + type: LinearTableCellItemType, + block: Builder.() -> Unit, + ): LinearTableCellItem { + return Builder(colSpan = colSpan, rowSpan = rowSpan, type = type).apply(block).build() + } + + val filler = + LinearTableCellItem( + type = LinearTableCellItemType.DATA, + colSpan = -1, + rowSpan = -1, + content = emptyList(), + ) + } +} + +enum class LinearTableCellItemType { + HEADER, + DATA, +} + +data class LinearBlockQuote( + val cite: String?, + val content: List, +) : LinearElement { + constructor(cite: String?, block: ListBuilderScope.() -> Unit) : this(cite = cite, content = ListBuilderScope(block).items) + + constructor(cite: String?, vararg elements: LinearElement) : this(cite = cite, content = elements.toList()) +} + +/** + * Primitives can not contain other elements + */ +sealed interface LinearPrimitive : LinearElement + +/** + * Represents a text element. For example a paragraph, or a header. + */ +data class LinearText( + val text: String, + val annotations: List, + val blockStyle: LinearTextBlockStyle, +) : LinearPrimitive { + constructor(text: String, blockStyle: LinearTextBlockStyle, vararg annotations: LinearTextAnnotation) : this(text = text, blockStyle = blockStyle, annotations = annotations.toList()) +} + +enum class LinearTextBlockStyle { + TEXT, + PRE_FORMATTED, + CODE_BLOCK, +} + +val LinearTextBlockStyle.shouldSoftWrap: Boolean + get() = this == LinearTextBlockStyle.TEXT + +/** + * Represents an image element + */ +data class LinearImage( + val sources: List, + val caption: LinearText?, + val link: String?, +) : LinearElement + +data class LinearImageSource( + val imgUri: String, + val widthPx: Int?, + val heightPx: Int?, + val pixelDensity: Float?, + val screenWidth: Int?, +) + +/** + * Represents a video element + */ +data class LinearVideo( + val sources: List, +) : LinearElement { + init { + require(sources.isNotEmpty()) { "At least one source must be provided" } + } + + val imageThumbnail: String? by lazy { + sources.firstOrNull { it.imageThumbnail != null }?.imageThumbnail + } + + val firstSource: LinearVideoSource + get() = sources.first() +} + +data class LinearVideoSource( + val uri: String, + // This might be different from the uri, for example for youtube videos where uri is the embed uri + val link: String, + val imageThumbnail: String?, + val widthPx: Int?, + val heightPx: Int?, + val mimeType: String?, +) + +/** + * Represents an audio element + */ +data class LinearAudio( + val sources: List, +) : LinearElement { + init { + require(sources.isNotEmpty()) { "At least one source must be provided" } + } + + val firstSource: LinearAudioSource + get() = sources.first() +} + +data class LinearAudioSource( + val uri: String, + val mimeType: String?, +) diff --git a/app/src/main/java/com/nononsenseapps/feeder/model/html/LinearTextAnnotation.kt b/app/src/main/java/com/nononsenseapps/feeder/model/html/LinearTextAnnotation.kt new file mode 100644 index 000000000..aa014daf4 --- /dev/null +++ b/app/src/main/java/com/nononsenseapps/feeder/model/html/LinearTextAnnotation.kt @@ -0,0 +1,54 @@ +package com.nononsenseapps.feeder.model.html + +data class LinearTextAnnotation( + val data: LinearTextAnnotationData, + /** + * Inclusive start index + */ + val start: Int, + /** + * Inclusive end index + */ + var end: Int, +) { + val endExclusive + get() = end + 1 +} + +sealed interface LinearTextAnnotationData + +data object LinearTextAnnotationH1 : LinearTextAnnotationData + +data object LinearTextAnnotationH2 : LinearTextAnnotationData + +data object LinearTextAnnotationH3 : LinearTextAnnotationData + +data object LinearTextAnnotationH4 : LinearTextAnnotationData + +data object LinearTextAnnotationH5 : LinearTextAnnotationData + +data object LinearTextAnnotationH6 : LinearTextAnnotationData + +data object LinearTextAnnotationBold : LinearTextAnnotationData + +data object LinearTextAnnotationItalic : LinearTextAnnotationData + +data object LinearTextAnnotationMonospace : LinearTextAnnotationData + +data object LinearTextAnnotationUnderline : LinearTextAnnotationData + +data object LinearTextAnnotationStrikethrough : LinearTextAnnotationData + +data object LinearTextAnnotationSuperscript : LinearTextAnnotationData + +data object LinearTextAnnotationSubscript : LinearTextAnnotationData + +data class LinearTextAnnotationFont( + val face: String, +) : LinearTextAnnotationData + +data object LinearTextAnnotationCode : LinearTextAnnotationData + +data class LinearTextAnnotationLink( + val href: String, +) : LinearTextAnnotationData diff --git a/app/src/main/java/com/nononsenseapps/feeder/model/html/LinearTextBuilder.kt b/app/src/main/java/com/nononsenseapps/feeder/model/html/LinearTextBuilder.kt new file mode 100644 index 000000000..00b76963a --- /dev/null +++ b/app/src/main/java/com/nononsenseapps/feeder/model/html/LinearTextBuilder.kt @@ -0,0 +1,184 @@ +package com.nononsenseapps.feeder.model.html + +class LinearTextBuilder : Appendable { + private data class MutableRange( + val item: T, + var start: Int, + var end: Int? = null, + ) + + private val text: StringBuilder = StringBuilder(16) + private val annotations: MutableList> = mutableListOf() + private val annotationsStack: MutableList> = mutableListOf() + private val mLastTwoChars: MutableList = mutableListOf() + + val length: Int + get() = text.length + + val lastTwoChars: List + get() = mLastTwoChars + + val endsWithWhitespace: Boolean + get() { + if (mLastTwoChars.isEmpty()) { + return true + } + mLastTwoChars.peekLatest()?.let { latest -> + // Non-breaking space (160) is not caught by trim or whitespace identification + if (latest.isWhitespace() || latest.code == 160) { + return true + } + } + + return false + } + + fun append(text: String) { + if (text.count() >= 2) { + mLastTwoChars.pushMaxTwo(text.secondToLast()) + } + if (text.isNotEmpty()) { + mLastTwoChars.pushMaxTwo(text.last()) + } + this.text.append(text) + } + + override fun append(char: Char): LinearTextBuilder { + mLastTwoChars.pushMaxTwo(char) + text.append(char) + return this + } + + override fun append(csq: CharSequence?): LinearTextBuilder { + if (csq == null) { + return this + } + + if (csq.count() >= 2) { + mLastTwoChars.pushMaxTwo(csq.secondToLast()) + } + if (csq.isNotEmpty()) { + mLastTwoChars.pushMaxTwo(csq.last()) + } + text.append(csq) + return this + } + + override fun append( + csq: CharSequence?, + start: Int, + end: Int, + ): java.lang.Appendable { + if (csq == null) { + return this + } + + if (end - start >= 2) { + mLastTwoChars.pushMaxTwo(csq[start + end - 2]) + } + if (end - start > 0) { + mLastTwoChars.pushMaxTwo(csq[start + end - 1]) + } + text.append(csq, start, end) + return this + } + + /** + * Applies the given [LinearTextAnnotationData] to any appended text until a corresponding [pop] is called. + * + * @return the index of the pushed annotation + */ + fun push(annotation: LinearTextAnnotationData): Int { + MutableRange(item = annotation, start = text.length).also { + annotations.add(it) + annotationsStack.add(it) + } + return annotationsStack.lastIndex + } + + /** + * Ends the style or annotation that was added via a push operation before. + */ + fun pop() { + check(annotationsStack.isNotEmpty()) { "No annotation to pop" } + // pop the last element + val item = annotationsStack.removeLast() + item.end = text.lastIndex + } + + /** + * Ends the annotation up to and including the pushLinearTextAnnotationData that returned the given index. + * + * @param index - the result of the a previous push in order to pop to + */ + fun pop(index: Int) { + check(index in annotationsStack.indices) { "No annotation at index $index: annotations size ${annotationsStack.size}" } + while (annotationsStack.lastIndex >= index) { + pop() + } + } + + fun toLinearText(blockStyle: LinearTextBlockStyle): LinearText { + // Chop of possible ending whitespace - looks bad in code blocks for instance + val trimmed = text.toString().trimEnd() + return LinearText( + text = trimmed, + blockStyle = blockStyle, + annotations = + annotations.map { + LinearTextAnnotation( + data = it.item, + start = it.start.coerceAtMost(trimmed.lastIndex), + end = (it.end ?: text.lastIndex).coerceAtMost(trimmed.lastIndex), + ) + }, + ) + } + + /** + * Clears the text and resets annotations to start at the beginning + */ + fun clearKeepingSpans() { + text.clear() + mLastTwoChars.clear() + // Get rid of completed annotations + annotations.clear() + + annotationsStack.forEach { + it.start = 0 + it.end = null + annotations.add(it) + } + } + + fun findClosestLink(): String? { + for (annotation in annotationsStack.reversed()) { + if (annotation.item is LinearTextAnnotationLink) { + return annotation.item.href + } + } + return null + } +} + +fun LinearTextBuilder.isEmpty() = lastTwoChars.isEmpty() + +fun LinearTextBuilder.isNotEmpty() = lastTwoChars.isNotEmpty() + +private fun CharSequence.secondToLast(): Char { + if (count() < 2) { + throw NoSuchElementException("List has less than two items.") + } + return this[lastIndex - 1] +} + +private fun MutableList.pushMaxTwo(item: T) { + this.add(0, item) + if (count() > 2) { + this.removeLast() + } +} + +private fun List.peekLatest(): T? { + return this.firstOrNull() +} diff --git a/app/src/main/java/com/nononsenseapps/feeder/ui/ActivityExceptionHandler.kt b/app/src/main/java/com/nononsenseapps/feeder/ui/ActivityExceptionHandler.kt index 6168fe7d2..2e89f0a1e 100644 --- a/app/src/main/java/com/nononsenseapps/feeder/ui/ActivityExceptionHandler.kt +++ b/app/src/main/java/com/nononsenseapps/feeder/ui/ActivityExceptionHandler.kt @@ -3,6 +3,7 @@ package com.nononsenseapps.feeder.ui import android.app.Activity import android.content.Intent import android.util.Log +import com.nononsenseapps.feeder.BuildConfig import com.nononsenseapps.feeder.util.emailCrashReportIntent import kotlin.system.exitProcess @@ -10,10 +11,13 @@ fun Activity.installExceptionHandler() { val mainHandler = Thread.getDefaultUncaughtExceptionHandler() Thread.setDefaultUncaughtExceptionHandler { thread, throwable -> try { - Log.w("FEEDER_PANIC", "Trying to report unhandled exception", throwable) - val intent = emailCrashReportIntent(throwable) - intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) - startActivity(intent) + // On emulator I just want a crash + if (!BuildConfig.DEBUG) { + Log.w("FEEDER_PANIC", "Trying to report unhandled exception", throwable) + val intent = emailCrashReportIntent(throwable) + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + startActivity(intent) + } } catch (e: Exception) { e.printStackTrace() } finally { diff --git a/app/src/main/java/com/nononsenseapps/feeder/ui/compose/feedarticle/ArticleScreen.kt b/app/src/main/java/com/nononsenseapps/feeder/ui/compose/feedarticle/ArticleScreen.kt index b71a1e5e0..987d1c518 100644 --- a/app/src/main/java/com/nononsenseapps/feeder/ui/compose/feedarticle/ArticleScreen.kt +++ b/app/src/main/java/com/nononsenseapps/feeder/ui/compose/feedarticle/ArticleScreen.kt @@ -1,13 +1,11 @@ package com.nononsenseapps.feeder.ui.compose.feedarticle import android.content.Intent -import android.util.Log import androidx.activity.compose.BackHandler import androidx.compose.animation.core.MutableTransitionState import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.focusGroup import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsetsSides import androidx.compose.foundation.layout.height @@ -57,18 +55,15 @@ import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.nononsenseapps.feeder.R import com.nononsenseapps.feeder.archmodel.TextToDisplay -import com.nononsenseapps.feeder.blob.blobFile import com.nononsenseapps.feeder.blob.blobFullFile -import com.nononsenseapps.feeder.blob.blobFullInputStream -import com.nononsenseapps.feeder.blob.blobInputStream import com.nononsenseapps.feeder.db.room.ID_UNSET import com.nononsenseapps.feeder.model.LocaleOverride import com.nononsenseapps.feeder.ui.compose.components.safeSemantics import com.nononsenseapps.feeder.ui.compose.feed.PlainTooltipBox +import com.nononsenseapps.feeder.ui.compose.html.linearArticleContent import com.nononsenseapps.feeder.ui.compose.icons.CustomFilled import com.nononsenseapps.feeder.ui.compose.icons.TextToSpeech import com.nononsenseapps.feeder.ui.compose.readaloud.HideableTTSPlayer -import com.nononsenseapps.feeder.ui.compose.text.htmlFormattedText import com.nononsenseapps.feeder.ui.compose.theme.SensibleTopAppBar import com.nononsenseapps.feeder.ui.compose.theme.SetStatusBarColorToMatchScrollableTopAppBar import com.nononsenseapps.feeder.ui.compose.utils.ImmutableHolder @@ -461,73 +456,24 @@ fun ArticleContent( // Can take a composition or two before viewstate is set to its actual values if (viewState.articleId > ID_UNSET) { when (viewState.textToDisplay) { - TextToDisplay.DEFAULT -> { - if (blobFile(viewState.articleId, filePathProvider.articleDir).isFile) { - try { - blobInputStream(viewState.articleId, filePathProvider.articleDir).use { - htmlFormattedText( - inputStream = it, - baseUrl = viewState.articleFeedUrl ?: "", - keyHolder = DefaultArticleItemKeyHolder(viewState.articleId), - ) { link -> - activityLauncher.openLink( - link = link, - toolbarColor = toolbarColor, - ) - } - } - } catch (e: Exception) { - // EOFException is possible - Log.e(LOG_TAG, "Could not open blob", e) - item { - Text(text = stringResource(id = R.string.failed_to_open_article)) - } - } - } else { - item { - Column { - Text(text = stringResource(id = R.string.failed_to_open_article)) - Text(text = stringResource(id = R.string.sync_to_fetch)) - } - } - } + TextToDisplay.DEFAULT, + TextToDisplay.FULLTEXT, + -> { + linearArticleContent( + articleContent = viewState.articleContent, + onLinkClick = { link -> + activityLauncher.openLink( + link = link, + toolbarColor = toolbarColor, + ) + }, + ) } TextToDisplay.LOADING_FULLTEXT -> { LoadingItem() } - TextToDisplay.FULLTEXT -> { - if (blobFullFile(viewState.articleId, filePathProvider.fullArticleDir).isFile) { - try { - blobFullInputStream( - viewState.articleId, - filePathProvider.fullArticleDir, - ).use { - htmlFormattedText( - inputStream = it, - baseUrl = viewState.articleFeedUrl ?: "", - keyHolder = FullArticleItemKeyHolder(viewState.articleId), - ) { link -> - activityLauncher.openLink( - link = link, - toolbarColor = toolbarColor, - ) - } - } - } catch (e: Exception) { - // EOFException is possible - Log.e(LOG_TAG, "Could not open blob", e) - item { - Text(text = stringResource(id = R.string.failed_to_open_article)) - } - } - } else { - // Already trigger load in effect above - LoadingItem() - } - } - TextToDisplay.FAILED_TO_LOAD_FULLTEXT -> { item { Text(text = stringResource(id = R.string.failed_to_fetch_full_article)) diff --git a/app/src/main/java/com/nononsenseapps/feeder/ui/compose/feedarticle/FeedArticleViewModel.kt b/app/src/main/java/com/nononsenseapps/feeder/ui/compose/feedarticle/FeedArticleViewModel.kt index 9a9379f6a..b12ddd254 100644 --- a/app/src/main/java/com/nononsenseapps/feeder/ui/compose/feedarticle/FeedArticleViewModel.kt +++ b/app/src/main/java/com/nononsenseapps/feeder/ui/compose/feedarticle/FeedArticleViewModel.kt @@ -1,5 +1,6 @@ package com.nononsenseapps.feeder.ui.compose.feedarticle +import android.util.Log import androidx.compose.runtime.Immutable import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.viewModelScope @@ -18,6 +19,7 @@ import com.nononsenseapps.feeder.archmodel.SwipeAsRead import com.nononsenseapps.feeder.archmodel.TextToDisplay import com.nononsenseapps.feeder.archmodel.ThemeOptions import com.nononsenseapps.feeder.base.DIAwareViewModel +import com.nononsenseapps.feeder.blob.blobFile import com.nononsenseapps.feeder.blob.blobFullFile import com.nononsenseapps.feeder.blob.blobFullInputStream import com.nononsenseapps.feeder.blob.blobInputStream @@ -35,6 +37,8 @@ import com.nononsenseapps.feeder.model.PlaybackStatus import com.nononsenseapps.feeder.model.TTSStateHolder import com.nononsenseapps.feeder.model.ThumbnailImage import com.nononsenseapps.feeder.model.UnsupportedContentType +import com.nononsenseapps.feeder.model.html.HtmlLinearizer +import com.nononsenseapps.feeder.model.html.LinearArticle import com.nononsenseapps.feeder.model.workmanager.requestFeedSync import com.nononsenseapps.feeder.ui.compose.feed.FeedListItem import com.nononsenseapps.feeder.ui.compose.feed.FeedOrTag @@ -255,6 +259,7 @@ class FeedArticleViewModel( } // Used to trigger state update + // TODO remove private val textToDisplayTrigger: MutableStateFlow = MutableStateFlow(0) private suspend fun getTextToDisplayFor(itemId: Long): TextToDisplay = @@ -368,6 +373,7 @@ class FeedArticleViewModel( -> 0 }, image = article.image, + articleContent = parseArticleContent(article, textToDisplay), ) } .stateIn( @@ -387,8 +393,61 @@ class FeedArticleViewModel( } } + private fun parseArticleContent( + article: Article, + textToDisplay: TextToDisplay, + ): LinearArticle { + // Can't use view state here because this function is called before view state is updated + val htmlLinearizer = HtmlLinearizer() + return when (textToDisplay) { + TextToDisplay.DEFAULT -> { + if (blobFile(article.id, filePathProvider.articleDir).isFile) { + try { + blobInputStream(article.id, filePathProvider.articleDir).use { + htmlLinearizer.linearize( + inputStream = it, + baseUrl = article.feedUrl ?: "", + ) + } + } catch (e: Exception) { + // EOFException is possible + Log.e(LOG_TAG, "Could not open blob", e) + LinearArticle(elements = emptyList()) + } + } else { + Log.e(LOG_TAG, "No default file to parse") + setTextToDisplayFor(article.id, TextToDisplay.FAILED_NOT_HTML) + LinearArticle(elements = emptyList()) + } + } + TextToDisplay.FULLTEXT -> { + if (blobFullFile(article.id, filePathProvider.fullArticleDir).isFile) { + try { + blobFullInputStream(article.id, filePathProvider.fullArticleDir).use { + htmlLinearizer.linearize( + inputStream = it, + baseUrl = article.feedUrl ?: "", + ) + } + } catch (e: Exception) { + // EOFException is possible + Log.e(LOG_TAG, "Could not open blob", e) + LinearArticle(elements = emptyList()) + } + } else { + Log.e(LOG_TAG, "No fulltext file to parse") + setTextToDisplayFor(article.id, TextToDisplay.FAILED_NOT_HTML) + LinearArticle(elements = emptyList()) + } + } + else -> { + LinearArticle(elements = emptyList()) + } + } + } + private suspend fun loadFullTextThenDisplayIt(itemId: Long) { - if (blobFullFile(viewState.value.articleId, filePathProvider.fullArticleDir).isFile) { + if (blobFullFile(itemId, filePathProvider.fullArticleDir).isFile) { setTextToDisplayFor(itemId, TextToDisplay.FULLTEXT) return } @@ -398,7 +457,7 @@ class FeedArticleViewModel( val result = fullTextParser.parseFullArticleIfMissing( object : FeedItemForFetching { - override val id = viewState.value.articleId + override val id = itemId override val link = link }, ) @@ -511,6 +570,10 @@ class FeedArticleViewModel( override fun setRead(value: Boolean) { repository.setFeedListFilterRead(value) } + + companion object { + private const val LOG_TAG = "FEEDERArticleViewModel" + } } @Immutable @@ -562,6 +625,7 @@ interface ArticleScreenViewState { val keyHolder: ArticleItemKeyHolder val wordCount: Int val image: ThumbnailImage? + val articleContent: LinearArticle } interface ArticleItemKeyHolder { @@ -667,6 +731,7 @@ data class FeedArticleScreenViewState( val isArticleOpen: Boolean = false, override val wordCount: Int = 0, override val image: ThumbnailImage? = null, + override val articleContent: LinearArticle = LinearArticle(elements = emptyList()), ) : FeedScreenViewState, ArticleScreenViewState sealed class TSSError diff --git a/app/src/main/java/com/nononsenseapps/feeder/ui/compose/feedarticle/ReaderView.kt b/app/src/main/java/com/nononsenseapps/feeder/ui/compose/feedarticle/ReaderView.kt index 9f2b23159..a7964adee 100644 --- a/app/src/main/java/com/nononsenseapps/feeder/ui/compose/feedarticle/ReaderView.kt +++ b/app/src/main/java/com/nononsenseapps/feeder/ui/compose/feedarticle/ReaderView.kt @@ -1,7 +1,6 @@ package com.nononsenseapps.feeder.ui.compose.feedarticle import android.util.Log -import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.LocalIndication import androidx.compose.foundation.clickable import androidx.compose.foundation.focusGroup @@ -80,7 +79,6 @@ val dateTimeFormat: DateTimeFormatter = DateTimeFormatter.ofLocalizedDateTime(FormatStyle.FULL, FormatStyle.SHORT) .withLocale(Locale.getDefault()) -@OptIn(ExperimentalFoundationApi::class) @Composable fun ReaderView( screenType: ScreenType, diff --git a/app/src/main/java/com/nononsenseapps/feeder/ui/compose/html/LinearArticleContent.kt b/app/src/main/java/com/nononsenseapps/feeder/ui/compose/html/LinearArticleContent.kt new file mode 100644 index 000000000..cb060f698 --- /dev/null +++ b/app/src/main/java/com/nononsenseapps/feeder/ui/compose/html/LinearArticleContent.kt @@ -0,0 +1,1445 @@ +package com.nononsenseapps.feeder.ui.compose.html + +import androidx.collection.ArrayMap +import androidx.compose.foundation.LocalIndication +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.indication +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.ClickableText +import androidx.compose.foundation.text.selection.DisableSelection +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.ErrorOutline +import androidx.compose.material.icons.outlined.PlayCircleOutline +import androidx.compose.material.icons.outlined.Terrain +import androidx.compose.material3.LocalTextStyle +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ProvideTextStyle +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.SideEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.BaselineShift +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewLightDark +import androidx.compose.ui.unit.dp +import coil.compose.AsyncImage +import coil.request.ImageRequest +import coil.size.Precision +import coil.size.Scale +import coil.size.Size +import com.nononsenseapps.feeder.R +import com.nononsenseapps.feeder.model.html.Coordinate +import com.nononsenseapps.feeder.model.html.LinearArticle +import com.nononsenseapps.feeder.model.html.LinearAudio +import com.nononsenseapps.feeder.model.html.LinearBlockQuote +import com.nononsenseapps.feeder.model.html.LinearElement +import com.nononsenseapps.feeder.model.html.LinearImage +import com.nononsenseapps.feeder.model.html.LinearImageSource +import com.nononsenseapps.feeder.model.html.LinearList +import com.nononsenseapps.feeder.model.html.LinearListItem +import com.nononsenseapps.feeder.model.html.LinearTable +import com.nononsenseapps.feeder.model.html.LinearTableCellItem +import com.nononsenseapps.feeder.model.html.LinearTableCellItemType +import com.nononsenseapps.feeder.model.html.LinearText +import com.nononsenseapps.feeder.model.html.LinearTextAnnotation +import com.nononsenseapps.feeder.model.html.LinearTextAnnotationBold +import com.nononsenseapps.feeder.model.html.LinearTextAnnotationCode +import com.nononsenseapps.feeder.model.html.LinearTextAnnotationFont +import com.nononsenseapps.feeder.model.html.LinearTextAnnotationH1 +import com.nononsenseapps.feeder.model.html.LinearTextAnnotationH2 +import com.nononsenseapps.feeder.model.html.LinearTextAnnotationH3 +import com.nononsenseapps.feeder.model.html.LinearTextAnnotationH4 +import com.nononsenseapps.feeder.model.html.LinearTextAnnotationH5 +import com.nononsenseapps.feeder.model.html.LinearTextAnnotationH6 +import com.nononsenseapps.feeder.model.html.LinearTextAnnotationItalic +import com.nononsenseapps.feeder.model.html.LinearTextAnnotationLink +import com.nononsenseapps.feeder.model.html.LinearTextAnnotationMonospace +import com.nononsenseapps.feeder.model.html.LinearTextAnnotationStrikethrough +import com.nononsenseapps.feeder.model.html.LinearTextAnnotationSubscript +import com.nononsenseapps.feeder.model.html.LinearTextAnnotationSuperscript +import com.nononsenseapps.feeder.model.html.LinearTextAnnotationUnderline +import com.nononsenseapps.feeder.model.html.LinearTextBlockStyle +import com.nononsenseapps.feeder.model.html.LinearVideo +import com.nononsenseapps.feeder.ui.compose.coil.RestrainedFillWidthScaling +import com.nononsenseapps.feeder.ui.compose.coil.RestrainedFitScaling +import com.nononsenseapps.feeder.ui.compose.coil.rememberTintedVectorPainter +import com.nononsenseapps.feeder.ui.compose.layouts.Table +import com.nononsenseapps.feeder.ui.compose.layouts.TableCell +import com.nononsenseapps.feeder.ui.compose.layouts.TableData +import com.nononsenseapps.feeder.ui.compose.text.WithBidiDeterminedLayoutDirection +import com.nononsenseapps.feeder.ui.compose.text.WithTooltipIfNotBlank +import com.nononsenseapps.feeder.ui.compose.text.asFontFamily +import com.nononsenseapps.feeder.ui.compose.text.rememberMaxImageWidth +import com.nononsenseapps.feeder.ui.compose.theme.CodeBlockBackground +import com.nononsenseapps.feeder.ui.compose.theme.CodeInlineStyle +import com.nononsenseapps.feeder.ui.compose.theme.LinkTextStyle +import com.nononsenseapps.feeder.ui.compose.theme.LocalDimens +import com.nononsenseapps.feeder.ui.compose.theme.OnCodeBlockBackground +import com.nononsenseapps.feeder.ui.compose.theme.hasImageAspectRatioInReader +import com.nononsenseapps.feeder.ui.compose.utils.ProvideScaledText +import com.nononsenseapps.feeder.ui.compose.utils.WithAllPreviewProviders +import com.nononsenseapps.feeder.ui.compose.utils.focusableInNonTouchMode +import com.nononsenseapps.feeder.util.logDebug +import kotlin.math.abs + +fun LazyListScope.linearArticleContent( + articleContent: LinearArticle, + onLinkClick: (String) -> Unit, +) { + items( + count = articleContent.elements.size, + contentType = { index -> articleContent.elements[index].lazyListContentType }, + ) { index -> + ProvideTextStyle( + MaterialTheme.typography.bodyLarge.merge( + TextStyle(color = MaterialTheme.colorScheme.onBackground), + ), + ) { + BoxWithConstraints( + modifier = Modifier.fillMaxWidth(), + contentAlignment = Alignment.Center, + ) { + LinearElementContent( + linearElement = articleContent.elements[index], + onLinkClick = onLinkClick, + allowHorizontalScroll = true, + modifier = + Modifier + .widthIn(max = minOf(maxWidth, LocalDimens.current.maxReaderWidth)) + .fillMaxWidth(), + ) + } + } + } +} + +@Composable +fun LinearElementContent( + linearElement: LinearElement, + allowHorizontalScroll: Boolean, + modifier: Modifier = Modifier, + onLinkClick: (String) -> Unit, +) { + when (linearElement) { + is LinearList -> + LinearListContent( + linearList = linearElement, + onLinkClick = onLinkClick, + allowHorizontalScroll = allowHorizontalScroll, + modifier = modifier, + ) + + is LinearImage -> + LinearImageContent( + linearImage = linearElement, + onLinkClick = onLinkClick, + modifier = modifier, + ) + + is LinearBlockQuote -> { + LinearBlockQuoteContent( + blockQuote = linearElement, + modifier = modifier, + onLinkClick = onLinkClick, + ) + } + + is LinearText -> + when (linearElement.blockStyle) { + LinearTextBlockStyle.TEXT -> { + LinearTextContent( + linearText = linearElement, + onLinkClick = onLinkClick, + modifier = modifier, + ) + } + + LinearTextBlockStyle.PRE_FORMATTED -> { +// PreFormattedBlock( + CodeBlock( + linearText = linearElement, + onLinkClick = onLinkClick, + allowHorizontalScroll = allowHorizontalScroll, + modifier = modifier, + ) + } + + LinearTextBlockStyle.CODE_BLOCK -> { + CodeBlock( + linearText = linearElement, + onLinkClick = onLinkClick, + allowHorizontalScroll = allowHorizontalScroll, + modifier = modifier, + ) + } + } + + is LinearTable -> + LinearTableContent( + linearTable = linearElement, + onLinkClick = onLinkClick, + allowHorizontalScroll = allowHorizontalScroll, + modifier = modifier, + ) + + is LinearAudio -> + LinearAudioContent( + linearAudio = linearElement, + onLinkClick = onLinkClick, + modifier = modifier, + ) + + is LinearVideo -> + LinearVideoContent( + linearVideo = linearElement, + onLinkClick = onLinkClick, + modifier = modifier, + ) + } +} + +@Composable +fun LinearAudioContent( + linearAudio: LinearAudio, + modifier: Modifier = Modifier, + onLinkClick: (String) -> Unit, +) { + Column( + modifier = + Modifier + .then(modifier), + verticalArrangement = Arrangement.spacedBy(8.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + DisableSelection { + ProvideScaledText( + style = + MaterialTheme.typography.bodyLarge.merge( + LinkTextStyle(), + ), + ) { + Text( + text = stringResource(R.string.touch_to_play_audio), + modifier = + Modifier.clickable { + onLinkClick(linearAudio.firstSource.uri) + }, + ) + } + } + } +} + +@Composable +fun LinearVideoContent( + linearVideo: LinearVideo, + modifier: Modifier = Modifier, + onLinkClick: (String) -> Unit, +) { + Column( + modifier = + Modifier + .then(modifier), + verticalArrangement = Arrangement.spacedBy(8.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + DisableSelection { + if (linearVideo.imageThumbnail != null) { + BoxWithConstraints( + contentAlignment = Alignment.Center, + modifier = + Modifier + .clip(RectangleShape) + .clickable { + linearVideo.firstSource.link.let(onLinkClick) + } + .fillMaxWidth(), + ) { + val maxImageWidth by rememberMaxImageWidth() + val pixelDensity = LocalDensity.current.density + + val imageWidth: Int = + remember(linearVideo.firstSource) { + when { + linearVideo.firstSource.widthPx != null -> linearVideo.firstSource.widthPx!! + else -> maxImageWidth + } + } + val imageHeight: Int? = null + val dimens = LocalDimens.current + + val contentScale = + remember(pixelDensity, dimens.hasImageAspectRatioInReader) { + if (dimens.hasImageAspectRatioInReader) { + RestrainedFitScaling(pixelDensity) + } else { + RestrainedFillWidthScaling(pixelDensity) + } + } + + AsyncImage( + model = + ImageRequest.Builder(LocalContext.current) + .data(linearVideo.imageThumbnail) + .scale(Scale.FIT) + // DO NOT use the actualSize parameter here + .size(Size(imageWidth, imageHeight ?: imageWidth)) + // If image is larger than requested size, scale down + // But if image is smaller, don't scale up + // Note that this is the pixels, not how it is scaled inside the ImageView + .precision(Precision.INEXACT) + .build(), + contentDescription = stringResource(R.string.touch_to_play_video), + placeholder = + rememberTintedVectorPainter( + Icons.Outlined.PlayCircleOutline, + ), + error = rememberTintedVectorPainter(Icons.Outlined.ErrorOutline), + contentScale = contentScale, + modifier = + Modifier + .widthIn(max = maxWidth) + .fillMaxWidth(), + ) + } + } + + ProvideScaledText( + style = + MaterialTheme.typography.bodyLarge.merge( + LinkTextStyle(), + ), + ) { + Text( + text = stringResource(R.string.touch_to_play_video), + modifier = + Modifier.clickable { + onLinkClick(linearVideo.firstSource.link) + }, + ) + } + } + } +} + +@Composable +fun LinearListContent( + linearList: LinearList, + allowHorizontalScroll: Boolean, + modifier: Modifier = Modifier, + onLinkClick: (String) -> Unit, +) { + Column( + modifier = + Modifier + .then(modifier), + verticalArrangement = Arrangement.spacedBy(8.dp), + horizontalAlignment = Alignment.Start, + ) { + linearList.items.forEachIndexed { itemIndex, item -> + Row( + verticalAlignment = Alignment.Top, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + // List item indicator here + if (linearList.ordered) { + Text("${itemIndex + 1}.") + } else { + Text("•") + } + + // Then the item content + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + horizontalAlignment = Alignment.Start, + ) { + item.content.forEach { element -> + LinearElementContent( + linearElement = element, + onLinkClick = onLinkClick, + allowHorizontalScroll = allowHorizontalScroll, + ) + } + } + } + } + } +} + +@Composable +fun LinearImageContent( + linearImage: LinearImage, + modifier: Modifier = Modifier, + onLinkClick: (String) -> Unit, +) { + if (linearImage.sources.isEmpty()) { + return + } + + val dimens = LocalDimens.current + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + horizontalAlignment = Alignment.CenterHorizontally, + modifier = + Modifier + .then(modifier), + ) { + DisableSelection { + BoxWithConstraints( + contentAlignment = Alignment.Center, + modifier = + Modifier + .clip(RectangleShape) + .clickable( + enabled = linearImage.link != null, + ) { + linearImage.link?.let(onLinkClick) + } + .fillMaxWidth(), + ) { + val maxImageWidth by rememberMaxImageWidth() + val pixelDensity = LocalDensity.current.density + val bestImage = + remember { + linearImage.getBestImageForMaxSize( + pixelDensity = pixelDensity, + maxWidth = maxImageWidth, + ) + } ?: return@BoxWithConstraints + + val imageWidth: Int = + remember(bestImage) { + when { + bestImage.pixelDensity != null -> maxImageWidth + bestImage.screenWidth != null -> bestImage.screenWidth + bestImage.widthPx != null -> bestImage.widthPx + else -> maxImageWidth + } + } + val imageHeight: Int? = + remember(bestImage) { + when { + bestImage.heightPx != null -> bestImage.heightPx + else -> null + } + } + + WithTooltipIfNotBlank(tooltip = linearImage.caption?.text ?: "") { + val contentScale = + remember(pixelDensity, dimens.hasImageAspectRatioInReader) { + if (dimens.hasImageAspectRatioInReader) { + RestrainedFitScaling(pixelDensity) + } else { + RestrainedFillWidthScaling(pixelDensity) + } + } + + SideEffect { + logDebug("JONAS", "imgUri ${bestImage.imgUri}") + } + + AsyncImage( + model = + ImageRequest.Builder(LocalContext.current) + .data(bestImage.imgUri) + .scale(Scale.FIT) + // DO NOT use the actualSize parameter here + .size(Size(imageWidth, imageHeight ?: imageWidth)) + // If image is larger than requested size, scale down + // But if image is smaller, don't scale up + // Note that this is the pixels, not how it is scaled inside the ImageView + .precision(Precision.INEXACT) + .build(), + contentDescription = linearImage.caption?.text, + placeholder = + rememberTintedVectorPainter( + Icons.Outlined.Terrain, + ), + error = rememberTintedVectorPainter(Icons.Outlined.ErrorOutline), + contentScale = contentScale, + modifier = + Modifier + .widthIn(max = maxWidth) + .fillMaxWidth(), +// .run { +// // This looks awful for small images +// dimens.imageAspectRatioInReader?.let { ratio -> +// aspectRatio(ratio) +// } ?: this +// }, + ) + } + } + } + + linearImage.caption?.let { caption -> + ProvideTextStyle( + LocalTextStyle.current.merge( + MaterialTheme.typography.labelMedium.merge( + TextStyle(color = MaterialTheme.colorScheme.onBackground), + ), + ), + ) { + LinearTextContent( + linearText = caption, + onLinkClick = onLinkClick, + ) + } + } + } +} + +private fun LinearImage.getBestImageForMaxSize( + pixelDensity: Float, + maxWidth: Int, +): LinearImageSource? = + sources.minByOrNull { candidate -> + val candidateSize = + when { + candidate.pixelDensity != null -> candidate.pixelDensity / pixelDensity + candidate.screenWidth != null -> candidate.screenWidth / maxWidth.toFloat() + candidate.widthPx != null -> candidate.widthPx / maxWidth.toFloat() + // Assume it corresponds to 1x pixel density + else -> 1.0f / pixelDensity + } + + abs(candidateSize - 1.0f) + } + +@Composable +fun LinearTextContent( + linearText: LinearText, + modifier: Modifier = Modifier, + softWrap: Boolean = true, + onLinkClick: (String) -> Unit, +) { + ProvideScaledText { + WithBidiDeterminedLayoutDirection(linearText.text) { + val interactionSource = remember { MutableInteractionSource() } + + val annotatedString = linearText.toAnnotatedString() + + // ClickableText prevents taps from deselecting selected text + // So use regular Text if possible + if (linearText.annotations.any { it.data is LinearTextAnnotationLink }) { + ClickableText( + text = annotatedString, + softWrap = softWrap, + style = LocalTextStyle.current, + modifier = + Modifier + .indication(interactionSource, LocalIndication.current) + .focusableInNonTouchMode(interactionSource = interactionSource) + .then(modifier), + ) { offset -> + annotatedString.getStringAnnotations("URL", offset, offset) + .firstOrNull() + ?.let { + onLinkClick(it.item) + } + } + } else { + Text( + text = annotatedString, + softWrap = softWrap, + modifier = + Modifier + .indication(interactionSource, LocalIndication.current) + .focusableInNonTouchMode(interactionSource = interactionSource) + .then(modifier), + ) + } + } + } +} + +@Composable +fun LinearBlockQuoteContent( + blockQuote: LinearBlockQuote, + modifier: Modifier = Modifier, + onLinkClick: (String) -> Unit, +) { + Surface( + color = MaterialTheme.colorScheme.surface, + shape = MaterialTheme.shapes.medium, + tonalElevation = 2.dp, + modifier = + Modifier + .padding(start = 8.dp) + .then(modifier), + ) { + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + horizontalAlignment = Alignment.Start, + modifier = Modifier.padding(8.dp), + ) { + blockQuote.content + .filterIsInstance() + .forEach { element -> + ProvideTextStyle( + MaterialTheme.typography.bodyLarge.merge( + SpanStyle( + fontWeight = FontWeight.Light, + ), + ), + ) { + LinearTextContent( + linearText = element, + onLinkClick = onLinkClick, + ) + } + } + + blockQuote.cite?.let { cite -> + ClickableText( + text = AnnotatedString(cite), + modifier = Modifier.align(Alignment.End), + style = + MaterialTheme.typography.bodySmall.merge( + SpanStyle( + color = MaterialTheme.colorScheme.tertiary, + fontStyle = FontStyle.Italic, + ), + ), + ) { + onLinkClick(cite) + } + } + } + } +} + +// @Composable +// fun PreFormattedBlock( +// linearText: LinearText, +// allowHorizontalScroll: Boolean, +// modifier: Modifier = Modifier, +// onLinkClick: (String) -> Unit, +// ) { +// val scrollState = rememberScrollState() +// val interactionSource = +// remember { MutableInteractionSource() } +// Surface( +// color = MaterialTheme.colorScheme.surface, +// shape = MaterialTheme.shapes.medium, +// modifier = +// Modifier +// .run { +// if (allowHorizontalScroll) { +// horizontalScroll(scrollState) +// } else { +// this +// } +// } +// .indication( +// interactionSource, +// LocalIndication.current, +// ) +// .focusableInNonTouchMode(interactionSource = interactionSource) +// .then(modifier), +// ) { +// Box(modifier = Modifier.padding(all = 4.dp)) { +// ProvideTextStyle( +// MaterialTheme.typography.bodyLarge.merge( +// MaterialTheme.colorScheme.onSurface, +// ), +// ) { +// LinearTextContent( +// linearText = linearText, +// onLinkClick = onLinkClick, +// softWrap = false, +// ) +// } +// } +// } +// } + +@Composable +fun CodeBlock( + linearText: LinearText, + allowHorizontalScroll: Boolean, + modifier: Modifier = Modifier, + onLinkClick: (String) -> Unit, +) { + val scrollState = rememberScrollState() + val interactionSource = + remember { MutableInteractionSource() } + Box( + modifier = + Modifier + .then(modifier), + contentAlignment = Alignment.TopStart, + ) { + Surface( + color = CodeBlockBackground(), + shape = MaterialTheme.shapes.medium, + modifier = + Modifier + .run { + if (allowHorizontalScroll) { + horizontalScroll(scrollState) + } else { + this + } + } + .indication( + interactionSource, + LocalIndication.current, + ) + .focusableInNonTouchMode(interactionSource = interactionSource), + ) { + Box( + contentAlignment = Alignment.TopStart, + modifier = Modifier.padding(8.dp), + ) { + ProvideTextStyle( + MaterialTheme.typography.bodyLarge.merge( + OnCodeBlockBackground(), + ), + ) { + LinearTextContent( + linearText = linearText, + onLinkClick = onLinkClick, + softWrap = false, + ) + } + } + } + } +} + +@Composable +fun LinearTableContent( + linearTable: LinearTable, + allowHorizontalScroll: Boolean, + modifier: Modifier = Modifier, + onLinkClick: (String) -> Unit, +) { + val borderColor = MaterialTheme.colorScheme.outlineVariant + Table( + tableData = linearTable.toTableData(), + allowHorizontalScroll = allowHorizontalScroll, + modifier = + Modifier + .then(modifier), + content = { row, column -> + Column( + verticalArrangement = Arrangement.spacedBy(4.dp), + horizontalAlignment = Alignment.Start, + modifier = + Modifier + .background( + // if table contains image, don't color rows alternatively + if (row % 2 == 0 || linearTable.cells.values.any { cell -> cell.content.any { it is LinearImage } }) { + MaterialTheme.colorScheme.background + } else { + MaterialTheme.colorScheme.surfaceVariant + }, + ) +// .border(1.dp, MaterialTheme.colorScheme.outlineVariant) + .drawWithContent { + drawContent() +// if (row < linearTable.rowCount - 1) { +// drawLine( +// color = borderColor, +// strokeWidth = 1.dp.toPx(), +// start = Offset(0f, size.height), +// end = Offset(size.width, size.height), +// ) +// } + // As a side effect, only draws borders if more than one column which is good + if (column < linearTable.colCount - 1) { + drawLine( + color = borderColor, + strokeWidth = 1.dp.toPx(), + start = Offset(size.width, 0f), + end = Offset(size.width, size.height), + ) + } + } + .padding(4.dp), + ) { + val cellItem = linearTable.cellAt(row = row, col = column) + cellItem?.let { + ProvideTextStyle( + value = + if (cellItem.type == LinearTableCellItemType.HEADER) { + MaterialTheme.typography.bodyLarge.merge( + TextStyle( + fontWeight = FontWeight.Bold, + color = + if (row % 2 == 0) { + MaterialTheme.colorScheme.onBackground + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + ), + ) + } else { + MaterialTheme.typography.bodyLarge.merge( + TextStyle( + color = + if (row % 2 == 0) { + MaterialTheme.colorScheme.onBackground + } else { + MaterialTheme.colorScheme.onSurface + }, + ), + ) + }, + ) { + for (element in it.content) { + LinearElementContent( + linearElement = element, + onLinkClick = onLinkClick, + modifier = Modifier.fillMaxWidth(), + allowHorizontalScroll = false, + ) + } + } + } + } + }, + ) +} + +val LinearElement.lazyListContentType: String + get() = + when (this) { + is LinearList -> "LinearList" + is LinearImage -> "LinearImage" + is LinearText -> "LinearText" + is LinearTable -> "LinearTable" + is LinearBlockQuote -> "LinearBlockQuote" + is LinearAudio -> "LinearAudio" + is LinearVideo -> "LinearVideo" + } + +@Composable +fun LinearText.toAnnotatedString(): AnnotatedString { + val builder = AnnotatedString.Builder() + builder.append(text) + annotations.forEach { annotation -> + when (val data = annotation.data) { + LinearTextAnnotationBold -> { + builder.addStyle( + start = annotation.start, + end = annotation.endExclusive, + style = SpanStyle(fontWeight = FontWeight.Bold), + ) + } + + is LinearTextAnnotationFont -> { + builder.addStyle( + start = annotation.start, + end = annotation.endExclusive, + style = SpanStyle(fontFamily = data.face.asFontFamily()), + ) + } + + LinearTextAnnotationH1, + LinearTextAnnotationH2, + LinearTextAnnotationH3, + LinearTextAnnotationH4, + LinearTextAnnotationH5, + LinearTextAnnotationH6, + -> { + builder.addStyle( + start = annotation.start, + end = annotation.endExclusive, + style = MaterialTheme.typography.headlineSmall.toSpanStyle(), + ) + } + + LinearTextAnnotationItalic -> { + builder.addStyle( + start = annotation.start, + end = annotation.endExclusive, + style = SpanStyle(fontStyle = FontStyle.Italic), + ) + } + + is LinearTextAnnotationLink -> { + builder.addStringAnnotation( + tag = "URL", + start = annotation.start, + end = annotation.endExclusive, + annotation = data.href, + ) + builder.addStyle( + start = annotation.start, + end = annotation.endExclusive, + style = LinkTextStyle().toSpanStyle(), + ) + } + + LinearTextAnnotationMonospace -> { + builder.addStyle( + start = annotation.start, + end = annotation.endExclusive, + style = SpanStyle(fontFamily = FontFamily.Monospace), + ) + } + + LinearTextAnnotationCode -> { + // Code blocks are already styled on the block level + if (blockStyle != LinearTextBlockStyle.CODE_BLOCK) { + builder.addStyle( + start = annotation.start, + end = annotation.endExclusive, + style = CodeInlineStyle(), + ) + } + } + + LinearTextAnnotationSubscript -> { + builder.addStyle( + start = annotation.start, + end = annotation.endExclusive, + style = SpanStyle(baselineShift = BaselineShift.Subscript), + ) + } + + LinearTextAnnotationSuperscript -> { + builder.addStyle( + start = annotation.start, + end = annotation.endExclusive, + style = SpanStyle(baselineShift = BaselineShift.Superscript), + ) + } + + LinearTextAnnotationUnderline -> { + builder.addStyle( + start = annotation.start, + end = annotation.endExclusive, + style = SpanStyle(textDecoration = TextDecoration.Underline), + ) + } + + LinearTextAnnotationStrikethrough -> { + builder.addStyle( + start = annotation.start, + end = annotation.endExclusive, + style = SpanStyle(textDecoration = TextDecoration.LineThrough), + ) + } + } + } + return builder.toAnnotatedString() +} + +@Composable +private fun PreviewContent(element: LinearElement) { + WithAllPreviewProviders { + Surface { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier.padding(8.dp), + ) { + LinearElementContent( + linearElement = element, + onLinkClick = {}, + allowHorizontalScroll = true, + ) + } + } + } +} + +@Preview +@Composable +private fun PreviewTextElement() { + val linearText = + LinearText( + text = "Hello, world future!", + blockStyle = LinearTextBlockStyle.TEXT, + LinearTextAnnotation( + data = LinearTextAnnotationStrikethrough, + start = 7, + end = 12, + ), + LinearTextAnnotation( + data = LinearTextAnnotationUnderline, + start = 14, + end = 20, + ), + ) + + PreviewContent(linearText) +} + +@PreviewLightDark +@Composable +private fun PreviewBlockQuote() { + val blockQuote = + LinearBlockQuote( + cite = "https://example.com", + content = + listOf( + LinearText( + text = "This is a block quote", + blockStyle = LinearTextBlockStyle.TEXT, + ), + ), + ) + + PreviewContent(blockQuote) +} + +@PreviewLightDark +@Composable +private fun PreviewCodeBlock() { + val codeBlock = + LinearText( + text = "fun main() {\n println(\"Hello, world!\")\n}", + blockStyle = LinearTextBlockStyle.CODE_BLOCK, + ) + + PreviewContent(codeBlock) +} + +@PreviewLightDark +@Composable +private fun PreviewPreFormatted() { + val preFormatted = + LinearText( + text = "This is pre-formatted text\n with some indentation", + blockStyle = LinearTextBlockStyle.PRE_FORMATTED, + ) + + PreviewContent(preFormatted) +} + +@PreviewLightDark +@Composable +private fun PreviewLinearOrderedListContent() { + val linearList = + LinearList( + ordered = true, + items = + listOf( + LinearListItem( + content = + listOf( + LinearText( + text = "List Item 1", + blockStyle = LinearTextBlockStyle.TEXT, + ), + ), + ), + LinearListItem( + content = + listOf( + LinearText( + text = "List Item 2", + blockStyle = LinearTextBlockStyle.TEXT, + ), + ), + ), + ), + ) + + PreviewContent(linearList) +} + +@PreviewLightDark +@Composable +private fun PreviewLinearUnorderedListContent() { + val linearList = + LinearList( + ordered = false, + items = + listOf( + LinearListItem( + content = + listOf( + LinearText( + text = "List Item 1", + blockStyle = LinearTextBlockStyle.TEXT, + ), + ), + ), + LinearListItem( + content = + listOf( + LinearText( + text = "List Item 2", + blockStyle = LinearTextBlockStyle.TEXT, + ), + ), + ), + ), + ) + + PreviewContent(linearList) +} + +@PreviewLightDark +@Composable +private fun PreviewLinearImageContent() { + val linearImage = + LinearImage( + sources = + listOf( + LinearImageSource( + imgUri = "https://example.com/image.jpg", + widthPx = 200, + heightPx = 200, + pixelDensity = 1f, + screenWidth = 200, + ), + ), + caption = + LinearText( + text = "This is an image caption", + blockStyle = LinearTextBlockStyle.TEXT, + ), + link = "https://example.com/image.jpg", + ) + + PreviewContent(linearImage) +} + +@PreviewLightDark +@Composable +private fun PreviewLinearTableContent() { + val linearTable = + LinearTable( + rowCount = 2, + colCount = 2, + cells = + listOf( + LinearTableCellItem( + type = LinearTableCellItemType.HEADER, + colSpan = 1, + rowSpan = 1, + content = + listOf( + LinearText( + text = "Cell 1", + blockStyle = LinearTextBlockStyle.TEXT, + ), + ), + ), + LinearTableCellItem( + type = LinearTableCellItemType.DATA, + colSpan = 1, + rowSpan = 1, + content = + listOf( + LinearText( + text = "Cell 2", + blockStyle = LinearTextBlockStyle.TEXT, + ), + ), + ), + LinearTableCellItem( + type = LinearTableCellItemType.HEADER, + colSpan = 1, + rowSpan = 1, + content = + listOf( + LinearText( + text = "Cell 3", + blockStyle = LinearTextBlockStyle.TEXT, + ), + ), + ), + LinearTableCellItem( + type = LinearTableCellItemType.DATA, + colSpan = 1, + rowSpan = 1, + content = + listOf( + LinearText( + text = "Cell 4", + blockStyle = LinearTextBlockStyle.TEXT, + ), + ), + ), + ), + ) + + PreviewContent(linearTable) +} + +@Preview +@Composable +private fun PreviewNestedTableContent() { + val linearTable = + LinearTable( + rowCount = 2, + colCount = 2, + cells = + listOf( + LinearTableCellItem( + colSpan = 1, + rowSpan = 1, + type = LinearTableCellItemType.DATA, + content = + listOf( + LinearImage( + sources = + listOf( + LinearImageSource( + imgUri = "https://example.com/image.jpg", + widthPx = null, + heightPx = null, + pixelDensity = null, + screenWidth = null, + ), + ), + caption = + LinearText( + text = "This is an image caption", + blockStyle = LinearTextBlockStyle.TEXT, + ), + link = "https://example.com/image.jpg", + ), + ), + ), + LinearTableCellItem( + colSpan = 1, + rowSpan = 1, + type = LinearTableCellItemType.DATA, + content = + listOf( + LinearList( + ordered = true, + items = + listOf( + LinearListItem( + content = + listOf( + LinearText( + text = "List Item 1", + blockStyle = LinearTextBlockStyle.TEXT, + ), + ), + ), + LinearListItem( + content = + listOf( + LinearText( + text = "List Item 2", + blockStyle = LinearTextBlockStyle.TEXT, + ), + ), + ), + LinearListItem( + content = + listOf( + LinearText( + text = "List Item 3", + blockStyle = LinearTextBlockStyle.TEXT, + ), + ), + ), + ), + ), + ), + ), + LinearTableCellItem( + colSpan = 1, + rowSpan = 1, + type = LinearTableCellItemType.DATA, + content = + listOf( + LinearText( + text = "fun main() {\n println(\"Hello, world!\")\n}", + blockStyle = LinearTextBlockStyle.CODE_BLOCK, + ), + ), + ), + LinearTableCellItem( + colSpan = 1, + rowSpan = 1, + type = LinearTableCellItemType.DATA, + content = + listOf( + LinearTable( + rowCount = 2, + colCount = 2, + cells = + listOf( + LinearTableCellItem( + colSpan = 1, + rowSpan = 1, + type = LinearTableCellItemType.HEADER, + content = + listOf( + LinearText( + text = "Cell 1", + blockStyle = LinearTextBlockStyle.TEXT, + ), + ), + ), + LinearTableCellItem( + colSpan = 1, + rowSpan = 1, + type = LinearTableCellItemType.HEADER, + content = + listOf( + LinearText( + text = "Cell 2", + blockStyle = LinearTextBlockStyle.TEXT, + ), + ), + ), + LinearTableCellItem( + colSpan = 1, + rowSpan = 1, + type = LinearTableCellItemType.DATA, + content = + listOf( + LinearText( + text = "Cell 3", + blockStyle = LinearTextBlockStyle.TEXT, + ), + ), + ), + LinearTableCellItem( + colSpan = 1, + rowSpan = 1, + type = LinearTableCellItemType.DATA, + content = + listOf( + LinearText( + text = "Cell 4", + blockStyle = LinearTextBlockStyle.TEXT, + ), + ), + ), + ), + ), + ), + ), + ), + ) + + PreviewContent(element = linearTable) +} + +@Preview +@Composable +private fun PreviewColSpanningTable() { + val linearTable = + LinearTable( + rowCount = 2, + colCount = 2, + cellsReal = + ArrayMap().apply { + putAll( + listOf( + Coordinate(row = 0, col = 0) to + LinearTableCellItem( + type = LinearTableCellItemType.HEADER, + colSpan = 2, + rowSpan = 1, + content = + listOf( + LinearText( + text = "Header 1 and 2", + blockStyle = LinearTextBlockStyle.TEXT, + ), + ), + ), + Coordinate(row = 1, col = 0) to + LinearTableCellItem( + type = LinearTableCellItemType.DATA, + colSpan = 1, + rowSpan = 1, + content = + listOf( + LinearText( + text = "Cell 1", + blockStyle = LinearTextBlockStyle.TEXT, + ), + ), + ), + Coordinate(row = 1, col = 1) to + LinearTableCellItem( + type = LinearTableCellItemType.DATA, + colSpan = 1, + rowSpan = 1, + content = + listOf( + LinearText( + text = "Cell 2", + blockStyle = LinearTextBlockStyle.TEXT, + ), + ), + ), + ), + ) + }, + ) + + PreviewContent(linearTable) +} + +fun LinearTable.toTableData(): TableData { + return TableData( + cells = + cells + .asSequence() + .filterNot { (_, cell) -> cell.isFiller } + .map { (coord, cell) -> + TableCell( + row = coord.row, + column = coord.col, + colSpan = + if (cell.colSpan == 0) { + colCount - coord.col + } else { + cell.colSpan + }, + rowSpan = + if (cell.rowSpan == 0) { + rowCount - coord.row + } else { + cell.rowSpan + }, + ) + } + .sortedWith( + compareBy( + { it.colSpan }, + { it.rowSpan }, + { it.row }, + { it.column }, + ), + ) + .toList(), + ) +} diff --git a/app/src/main/java/com/nononsenseapps/feeder/ui/compose/layouts/Table.kt b/app/src/main/java/com/nononsenseapps/feeder/ui/compose/layouts/Table.kt new file mode 100644 index 000000000..018d42dda --- /dev/null +++ b/app/src/main/java/com/nononsenseapps/feeder/ui/compose/layouts/Table.kt @@ -0,0 +1,226 @@ +package com.nononsenseapps.feeder.ui.compose.layouts + +import androidx.compose.foundation.ScrollState +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.rememberScrollState +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableStateMapOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.Layout +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Constraints +import androidx.compose.ui.unit.dp + +@Composable +fun Table( + tableData: TableData, + modifier: Modifier = Modifier, + allowHorizontalScroll: Boolean = true, + caption: @Composable (() -> Unit)? = null, + content: @Composable (row: Int, column: Int) -> Unit, +) { + val columnWidths = remember { mutableStateMapOf() } + val rowHeights = remember { mutableStateMapOf() } + + val horizontalScrollState: ScrollState = rememberScrollState() + + Box( + modifier = + Modifier + .then(modifier), + contentAlignment = Alignment.Center, + ) { + Layout( + modifier = + Modifier + .run { + if (allowHorizontalScroll) { + horizontalScroll(horizontalScrollState) + } else { + this + } + }, + content = { + for (tableCell in tableData.cells) { + if (tableCell.rowSpan > 0 && tableCell.colSpan > 0) { + content(tableCell.row, tableCell.column) + } + } + }, + ) { measurables, constraints -> + val placeables = + measurables.mapIndexed { index, measurable -> + val tableCell = tableData.cells[index] + + val minWidth = (0 until tableCell.colSpan).sumOf { columnWidths.getOrDefault(tableCell.column + it, 0) } + val minHeight = (0 until tableCell.rowSpan).sumOf { rowHeights.getOrDefault(tableCell.row + it, 0) } + + measurable.measure( + Constraints( + minWidth = minWidth, + maxWidth = Constraints.Infinity, + minHeight = minHeight, + maxHeight = Constraints.Infinity, + ), + ) + } + + // Calculate max column width and max row height + // This depends on the fact that the items are sorted non-spanning items first + placeables.forEachIndexed { index, placeable -> + val tableCell = tableData.cells[index] + + val widthPerColumn = placeable.width / tableCell.colSpan + for (col in tableCell.column until tableCell.column + tableCell.colSpan) { + columnWidths[col] = maxOf(columnWidths[col] ?: 0, widthPerColumn) + } + + val heightPerRow = placeable.height / tableCell.rowSpan + for (row in tableCell.row until tableCell.row + tableCell.rowSpan) { + rowHeights[row] = maxOf(rowHeights[row] ?: 0, heightPerRow) + } + } + + // Calculate total width and height + val totalWidth = columnWidths.values.sumOf { it } + val totalHeight = rowHeights.values.sumOf { it } + + layout(width = totalWidth, height = totalHeight) { + placeables.forEachIndexed { index, placeable -> + val tableCell = tableData.cells[index] + + val x = (0 until tableCell.column).sumOf { columnWidths.getOrDefault(it, 0) } + val y = (0 until tableCell.row).sumOf { rowHeights.getOrDefault(it, 0) } + + placeable.place(x, y) + } + } + } + } +} + +@Preview +@Composable +private fun TableFixedPreview() { + Table(tableData = TableData(3, 3)) { row, column -> + Box( + modifier = + Modifier + .size(25.dp) + .background(if ((row + column) % 2 == 0) Color.Gray else Color.White), + ) + } +} + +@Preview +@Composable +private fun TableDifferentColumnsPreview() { + Table( + tableData = TableData(3, 3), + modifier = + Modifier + .widthIn(max = 150.dp) + .border(1.dp, Color.Red) + .padding(24.dp), + ) { row, column -> + Box( + modifier = + Modifier + .background(if ((row + column) % 2 == 0) Color.Gray else Color.White), + ) { + Row { + for (i in 0..row) { + Text(text = "Row $row Column $column") + } + } + } + } +} + +@Preview +@Composable +private fun TableWithPaddingPreview() { + Table( + tableData = TableData(3, 3), + modifier = + Modifier + .border(1.dp, Color.Red) + .padding(24.dp), + ) { row, column -> + Box( + modifier = + Modifier + .size(25.dp) + .background(if ((row + column) % 2 == 0) Color.Gray else Color.White), + ) + } +} + +@Preview +@Composable +private fun TableCaptionPreview() { + Surface { + Table(tableData = TableData(3, 3), caption = { + Text("Table caption") + }) { row, column -> + Box( + modifier = + Modifier + .size(25.dp) + .background(if ((row + column) % 2 == 0) Color.Gray else Color.White), + ) + } + } +} + +data class TableData( + val cells: List, +) { + constructor(row: Int, column: Int) : this( + List(row * column) { index -> + TableCell( + row = index / column, + rowSpan = 1, + column = index % column, + colSpan = 1, + ) + }, + ) + + init { + var lastRowSpan = 0 + var lastColSpan = 0 + for (cell in cells) { + check(cell.rowSpan >= lastRowSpan) { + "Cells must be sorted in order of increasing spans" + } + check(cell.colSpan >= lastColSpan) { + "Cells must be sorted in order of increasing spans" + } + lastRowSpan = cell.rowSpan + lastColSpan = cell.colSpan + } + } + + val rows: Int = cells.maxOf { it.row + it.rowSpan } + val columns: Int = cells.maxOf { it.column + it.colSpan } +} + +data class TableCell( + val row: Int, + val rowSpan: Int, + val column: Int, + val colSpan: Int, +) diff --git a/app/src/main/java/com/nononsenseapps/feeder/ui/compose/text/EagerComposer.kt b/app/src/main/java/com/nononsenseapps/feeder/ui/compose/text/EagerComposer.kt deleted file mode 100644 index 3d2d064f9..000000000 --- a/app/src/main/java/com/nononsenseapps/feeder/ui/compose/text/EagerComposer.kt +++ /dev/null @@ -1,81 +0,0 @@ -package com.nononsenseapps.feeder.ui.compose.text - -import androidx.compose.runtime.Composable - -class EagerComposer( - private val paragraphEmitter: @Composable (AnnotatedParagraphStringBuilder, TextStyler?) -> Unit, -) : HtmlComposer() { - private val paragraphs: MutableList<@Composable () -> Unit> = mutableListOf() - - @Composable - fun render(): Boolean { - emitParagraph() - val result = paragraphs.isNotEmpty() - for (p in paragraphs) { - p() - } - paragraphs.clear() - return result - } - - override fun appendImage( - link: String?, - onLinkClick: (String) -> Unit, - block: @Composable (() -> Unit) -> Unit, - ) { - emitParagraph() - - val url = link ?: findClosestLink() - val onClick: (() -> Unit) = - when { - url?.isNotBlank() == true -> { - { - onLinkClick(url) - } - } - else -> { - {} - } - } - - paragraphs.add { - block(onClick) - } - } - - override fun emitParagraph(): Boolean { - // List items emit dots and non-breaking space. Don't newline after that - if (builder.isEmpty() || builder.endsWithNonBreakingSpace) { - // Nothing to emit, and nothing to reset - return false - } - - // Important that we reference the correct builder in the lambda - reset will create a new - // builder and the lambda will run after that - val actualBuilder = builder - val actualTextStyle = textStyleStack.lastOrNull() - - paragraphs.add { - paragraphEmitter(actualBuilder, actualTextStyle) - } - resetAfterEmit() - return true - } - - private fun resetAfterEmit() { - builder = AnnotatedParagraphStringBuilder() - - for (span in spanStack) { - when (span) { - is SpanWithStyle -> builder.pushStyle(span.spanStyle) - is SpanWithAnnotation -> - builder.pushStringAnnotation( - tag = span.tag, - annotation = span.annotation, - ) - is SpanWithComposableStyle -> builder.pushComposableStyle(span.spanStyle) - is SpanWithVerbatim -> builder.pushVerbatimTtsAnnotation(span.verbatim) - } - } - } -} diff --git a/app/src/main/java/com/nononsenseapps/feeder/ui/compose/text/HtmlComposer.kt b/app/src/main/java/com/nononsenseapps/feeder/ui/compose/text/HtmlComposer.kt index d095afab9..1d7c8e2c2 100644 --- a/app/src/main/java/com/nononsenseapps/feeder/ui/compose/text/HtmlComposer.kt +++ b/app/src/main/java/com/nononsenseapps/feeder/ui/compose/text/HtmlComposer.kt @@ -2,19 +2,9 @@ package com.nononsenseapps.feeder.ui.compose.text import androidx.compose.runtime.Composable import androidx.compose.ui.text.SpanStyle -import androidx.compose.ui.text.TextStyle - -abstract class HtmlComposer : HtmlParser() { - abstract fun appendImage( - link: String? = null, - onLinkClick: (String) -> Unit, - block: @Composable (() -> Unit) -> Unit, - ) -} abstract class HtmlParser { protected val spanStack: MutableList = mutableListOf() - protected val textStyleStack: MutableList = mutableListOf() // The identity of this will change - do not reference it in blocks protected var builder: AnnotatedParagraphStringBuilder = AnnotatedParagraphStringBuilder() @@ -42,38 +32,7 @@ abstract class HtmlParser { annotation: String, ): Int = builder.pushStringAnnotation(tag = tag, annotation = annotation) - fun pushComposableStyle(style: @Composable () -> SpanStyle): Int = builder.pushComposableStyle(style) - - fun popComposableStyle(index: Int) = builder.popComposableStyle(index) - - fun pushTextStyle(style: TextStyler) = textStyleStack.add(style) - - fun popTextStyle() = textStyleStack.removeLastOrNull() - fun popSpan() = spanStack.removeLast() - - protected fun findClosestLink(): String? { - for (span in spanStack.reversed()) { - if (span is SpanWithAnnotation && span.tag == "URL") { - return span.annotation - } - } - return null - } -} - -inline fun HtmlComposer.withTextStyle( - textStyler: TextStyler, - crossinline block: HtmlComposer.() -> R, -): R { - emitParagraph() - pushTextStyle(textStyler) - return try { - block() - } finally { - emitParagraph() - popTextStyle() - } } inline fun HtmlParser.withParagraph(crossinline block: HtmlParser.() -> R): R { @@ -101,20 +60,6 @@ inline fun HtmlParser.withStyle( } } -inline fun HtmlComposer.withComposableStyle( - noinline style: @Composable () -> SpanStyle, - crossinline block: HtmlComposer.() -> R, -): R { - pushSpan(SpanWithComposableStyle(style)) - val index = pushComposableStyle(style) - return try { - block() - } finally { - popComposableStyle(index) - popSpan() - } -} - inline fun HtmlParser.withAnnotation( tag: String, annotation: String, @@ -148,8 +93,3 @@ data class SpanWithComposableStyle( data class SpanWithVerbatim( val verbatim: String, ) : Span() - -interface TextStyler { - @Composable - fun textStyle(): TextStyle -} diff --git a/app/src/main/java/com/nononsenseapps/feeder/ui/compose/text/HtmlToComposable.kt b/app/src/main/java/com/nononsenseapps/feeder/ui/compose/text/HtmlToComposable.kt index b046fa3f5..58cd24948 100644 --- a/app/src/main/java/com/nononsenseapps/feeder/ui/compose/text/HtmlToComposable.kt +++ b/app/src/main/java/com/nononsenseapps/feeder/ui/compose/text/HtmlToComposable.kt @@ -1,1207 +1,35 @@ package com.nononsenseapps.feeder.ui.compose.text -import android.util.Log -import androidx.compose.foundation.LocalIndication -import androidx.compose.foundation.clickable -import androidx.compose.foundation.horizontalScroll -import androidx.compose.foundation.indication -import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.BoxWithConstraintsScope -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.ColumnScope -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.aspectRatio -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.layout.widthIn -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.LazyListScope -import androidx.compose.foundation.lazy.LazyRow -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.text.ClickableText -import androidx.compose.foundation.text.selection.DisableSelection -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.outlined.ErrorOutline -import androidx.compose.material.icons.outlined.Terrain -import androidx.compose.material3.LocalTextStyle -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.derivedStateOf -import androidx.compose.runtime.getValue -import androidx.compose.runtime.key import androidx.compose.runtime.remember -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.RectangleShape -import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.SpanStyle -import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontFamily -import androidx.compose.ui.text.font.FontStyle -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.BaselineShift -import androidx.compose.ui.text.style.TextDecoration -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp -import coil.compose.AsyncImage -import coil.request.ImageRequest -import coil.size.Precision -import coil.size.Scale -import coil.size.Size -import com.nononsenseapps.feeder.R -import com.nononsenseapps.feeder.ui.compose.coil.RestrainedFillWidthScaling -import com.nononsenseapps.feeder.ui.compose.coil.RestrainedFitScaling -import com.nononsenseapps.feeder.ui.compose.coil.rememberTintedVectorPainter import com.nononsenseapps.feeder.ui.compose.feed.PlainTooltipBox -import com.nononsenseapps.feeder.ui.compose.feedarticle.ArticleItemKeyHolder -import com.nononsenseapps.feeder.ui.compose.theme.BlockQuoteStyle -import com.nononsenseapps.feeder.ui.compose.theme.CodeBlockBackground -import com.nononsenseapps.feeder.ui.compose.theme.CodeBlockStyle -import com.nononsenseapps.feeder.ui.compose.theme.CodeInlineStyle -import com.nononsenseapps.feeder.ui.compose.theme.FeederTheme -import com.nononsenseapps.feeder.ui.compose.theme.LinkTextStyle -import com.nononsenseapps.feeder.ui.compose.theme.LocalDimens -import com.nononsenseapps.feeder.ui.compose.theme.hasImageAspectRatioInReader -import com.nononsenseapps.feeder.ui.compose.utils.ProvideScaledText -import com.nononsenseapps.feeder.ui.compose.utils.focusableInNonTouchMode -import com.nononsenseapps.feeder.ui.text.Video -import com.nononsenseapps.feeder.ui.text.getVideo import com.nononsenseapps.feeder.util.asUTF8Sequence -import org.jsoup.Jsoup -import org.jsoup.helper.StringUtil import org.jsoup.nodes.Element -import org.jsoup.nodes.Node import org.jsoup.nodes.TextNode -import java.io.InputStream -import kotlin.math.abs import kotlin.math.roundToInt -import kotlin.random.Random -private const val LOG_TAG = "FEEDER_HTMLTOCOM" - -fun LazyListScope.htmlFormattedText( - keyHolder: ArticleItemKeyHolder, - inputStream: InputStream, - baseUrl: String, - onLinkClick: (String) -> Unit, -) { - try { - Jsoup.parse(inputStream, null, baseUrl) - ?.body() - ?.let { body -> - formatBody( - element = body, - baseUrl = baseUrl, - keyHolder = keyHolder, - onLinkClick = onLinkClick, - ) - } - } catch (e: Exception) { - Log.e(LOG_TAG, "htmlFormattingFailed", e) - } -} - -@Composable -private fun ParagraphText( - paragraphBuilder: AnnotatedParagraphStringBuilder, - textStyler: TextStyler?, - modifier: Modifier = Modifier, - onLinkClick: (String) -> Unit, -) { - val paragraph = paragraphBuilder.rememberComposableAnnotatedString() - - ProvideScaledText( - textStyler?.textStyle() ?: MaterialTheme.typography.bodyLarge.merge( - TextStyle(color = MaterialTheme.colorScheme.onBackground), - ), - ) { - WithBidiDeterminedLayoutDirection(paragraph.text) { - val interactionSource = remember { MutableInteractionSource() } - // ClickableText prevents taps from deselecting selected text - // So use regular Text if possible - if ( - paragraph.getStringAnnotations("URL", 0, paragraph.length) - .isNotEmpty() - ) { - ClickableText( - text = paragraph, - style = LocalTextStyle.current, - modifier = - modifier - .indication(interactionSource, LocalIndication.current) - .focusableInNonTouchMode(interactionSource = interactionSource), - ) { offset -> - paragraph.getStringAnnotations("URL", offset, offset) - .firstOrNull() - ?.let { - onLinkClick(it.item) - } - } - } else { - Text( - text = paragraph, - modifier = - modifier - .indication(interactionSource, LocalIndication.current) - .focusableInNonTouchMode(interactionSource = interactionSource), - ) - } - } - } -} - -private fun LazyListScope.formatBody( - element: Element, - baseUrl: String, - keyHolder: ArticleItemKeyHolder, - onLinkClick: (String) -> Unit, -) { - val composer = - LazyListComposer(this, keyHolder = keyHolder) { paragraphBuilder, textStyler -> - val dimens = LocalDimens.current - ParagraphText( - paragraphBuilder = paragraphBuilder, - textStyler = textStyler, - modifier = - Modifier - .width(dimens.maxReaderWidth), - onLinkClick = onLinkClick, - ) - } - - composer.appendTextChildren( - element.childNodes(), - baseUrl = baseUrl, - onLinkClick = onLinkClick, - keyHolder = keyHolder, - ) - - composer.emitParagraph() -} - -fun isHiddenByCSS(element: Element): Boolean { - val style = element.attr("style") ?: "" - return style.contains("display:") && style.contains("none") -} - -private fun HtmlComposer.appendTextChildren( - nodes: List, - preFormatted: Boolean = false, - baseUrl: String, - keyHolder: ArticleItemKeyHolder, - onLinkClick: (String) -> Unit, -) { - var node = nodes.firstOrNull() - while (node != null) { - when (node) { - is TextNode -> { - if (preFormatted) { - append(node.wholeText) - } else { - node.appendCorrectlyNormalizedWhiteSpace( - this, - stripLeading = endsWithWhitespace, - ) - } - } - - is Element -> { - val element = node - - if (isHiddenByCSS(element)) { - // Element is not supposed to be shown because javascript and/or tracking - node = node.nextSibling() - continue - } - - when (element.tagName()) { - "p" -> { - // Readability4j inserts p-tags in divs for algorithmic purposes. - // They screw up formatting. - if (node.hasClass("readability-styled")) { - appendTextChildren( - element.childNodes(), - baseUrl = baseUrl, - onLinkClick = onLinkClick, - keyHolder = keyHolder, - ) - } else { - withParagraph { - appendTextChildren( - element.childNodes(), - baseUrl = baseUrl, - onLinkClick = onLinkClick, - keyHolder = keyHolder, - ) - } - } - } - - "br" -> append('\n') - // TODO set heading() semantic tag on headers - "h1" -> { - withParagraph { - withComposableStyle( - style = { MaterialTheme.typography.headlineSmall.toSpanStyle() }, - ) { - element.appendCorrectlyNormalizedWhiteSpaceRecursively( - this, - stripLeading = endsWithWhitespace, - ) - } - } - } - - "h2" -> { - withParagraph { - withComposableStyle( - style = { MaterialTheme.typography.headlineSmall.toSpanStyle() }, - ) { - element.appendCorrectlyNormalizedWhiteSpaceRecursively( - this, - stripLeading = endsWithWhitespace, - ) - } - } - } - - "h3" -> { - withParagraph { - withComposableStyle( - style = { MaterialTheme.typography.headlineSmall.toSpanStyle() }, - ) { - element.appendCorrectlyNormalizedWhiteSpaceRecursively( - this, - stripLeading = endsWithWhitespace, - ) - } - } - } - - "h4" -> { - withParagraph { - withComposableStyle( - style = { MaterialTheme.typography.headlineSmall.toSpanStyle() }, - ) { - element.appendCorrectlyNormalizedWhiteSpaceRecursively( - this, - stripLeading = endsWithWhitespace, - ) - } - } - } - - "h5" -> { - withParagraph { - withComposableStyle( - style = { MaterialTheme.typography.headlineSmall.toSpanStyle() }, - ) { - element.appendCorrectlyNormalizedWhiteSpaceRecursively( - this, - stripLeading = endsWithWhitespace, - ) - } - } - } - - "h6" -> { - withParagraph { - withComposableStyle( - style = { MaterialTheme.typography.headlineSmall.toSpanStyle() }, - ) { - element.appendCorrectlyNormalizedWhiteSpaceRecursively( - this, - stripLeading = endsWithWhitespace, - ) - } - } - } - - "strong", "b" -> { - withStyle(SpanStyle(fontWeight = FontWeight.Bold)) { - appendTextChildren( - element.childNodes(), - baseUrl = baseUrl, - onLinkClick = onLinkClick, - keyHolder = keyHolder, - ) - } - } - - "i", "em", "cite", "dfn" -> { - withStyle(SpanStyle(fontStyle = FontStyle.Italic)) { - appendTextChildren( - element.childNodes(), - baseUrl = baseUrl, - onLinkClick = onLinkClick, - keyHolder = keyHolder, - ) - } - } - - "tt" -> { - withStyle(SpanStyle(fontFamily = FontFamily.Monospace)) { - appendTextChildren( - element.childNodes(), - baseUrl = baseUrl, - onLinkClick = onLinkClick, - keyHolder = keyHolder, - ) - } - } - - "u" -> { - withStyle(SpanStyle(textDecoration = TextDecoration.Underline)) { - appendTextChildren( - element.childNodes(), - baseUrl = baseUrl, - onLinkClick = onLinkClick, - keyHolder = keyHolder, - ) - } - } - - "sup" -> { - withStyle(SpanStyle(baselineShift = BaselineShift.Superscript)) { - appendTextChildren( - element.childNodes(), - baseUrl = baseUrl, - onLinkClick = onLinkClick, - keyHolder = keyHolder, - ) - } - } - - "sub" -> { - withStyle(SpanStyle(baselineShift = BaselineShift.Subscript)) { - appendTextChildren( - element.childNodes(), - baseUrl = baseUrl, - onLinkClick = onLinkClick, - keyHolder = keyHolder, - ) - } - } - - "font" -> { - val fontFamily: FontFamily? = element.attr("face")?.asFontFamily() - withStyle(SpanStyle(fontFamily = fontFamily)) { - appendTextChildren( - element.childNodes(), - baseUrl = baseUrl, - onLinkClick = onLinkClick, - keyHolder = keyHolder, - ) - } - } - - "pre" -> { - appendTextChildren( - element.childNodes(), - preFormatted = true, - baseUrl = baseUrl, - onLinkClick = onLinkClick, - keyHolder = keyHolder, - ) - } - - "code" -> { - if (element.parent()?.tagName() == "pre") { - emitParagraph() - - when (this) { - is LazyListComposer -> { - val composer = - EagerComposer { paragraphBuilder, textStyler -> - val dimens = LocalDimens.current - val scrollState = rememberScrollState() - val interactionSource = - remember { MutableInteractionSource() } - Surface( - color = CodeBlockBackground(), - shape = MaterialTheme.shapes.medium, - modifier = - Modifier - .horizontalScroll( - state = scrollState, - ) - .width(dimens.maxReaderWidth) - .indication( - interactionSource, - LocalIndication.current, - ) - .focusableInNonTouchMode(interactionSource = interactionSource), - ) { - Box(modifier = Modifier.padding(all = 4.dp)) { - Text( - text = paragraphBuilder.rememberComposableAnnotatedString(), - style = - textStyler?.textStyle() - ?: CodeBlockStyle(), - softWrap = false, - ) - } - } - } - - with(composer) { - item(keyHolder) { - appendTextChildren( - element.childNodes(), - preFormatted = true, - baseUrl = baseUrl, - onLinkClick = onLinkClick, - keyHolder = keyHolder, - ) - emitParagraph() - render() - } - } - } - - is EagerComposer -> { - // Should never happen as far as I know. But render text just in - // case - appendTextChildren( - element.childNodes(), - preFormatted = true, - baseUrl = baseUrl, - onLinkClick = onLinkClick, - keyHolder = keyHolder, - ) - emitParagraph() - } - } - } else { - // inline code - withComposableStyle( - style = { CodeInlineStyle() }, - ) { - appendTextChildren( - element.childNodes(), - preFormatted = preFormatted, - baseUrl = baseUrl, - onLinkClick = onLinkClick, - keyHolder = keyHolder, - ) - } - } - } - - "blockquote" -> { - withParagraph { - withComposableStyle( - style = { BlockQuoteStyle() }, - ) { - appendTextChildren( - element.childNodes(), - baseUrl = baseUrl, - onLinkClick = onLinkClick, - keyHolder = keyHolder, - ) - } - } - } - - "a" -> { - withComposableStyle( - style = { LinkTextStyle().toSpanStyle() }, - ) { - withAnnotation("URL", element.attr("abs:href") ?: "") { - appendTextChildren( - element.childNodes(), - baseUrl = baseUrl, - onLinkClick = onLinkClick, - keyHolder = keyHolder, - ) - } - } - } - - "figcaption" -> { - // If not inside figure then FullTextParsing just failed - if (element.parent()?.tagName() == "figure") { - appendTextChildren( - nodes = element.childNodes(), - preFormatted = preFormatted, - baseUrl = baseUrl, - onLinkClick = onLinkClick, - keyHolder = keyHolder, - ) - } - } - - "figure" -> { - emitParagraph() - - // Wordpress likes nested figures to get images side by side - if (this is LazyListComposer) { - val imgElement = element.firstBestDescendantImg(baseUrl = baseUrl) - - if (imgElement != null) { - val composer = - EagerComposer { paragraphBuilder, textStyler -> - val dimens = LocalDimens.current - ParagraphText( - paragraphBuilder = paragraphBuilder, - textStyler = textStyler, - modifier = - Modifier - .width(dimens.maxReaderWidth), - onLinkClick = onLinkClick, - ) - } - - item(keyHolder) { - with(composer) { - val dimens = LocalDimens.current - Column( - verticalArrangement = Arrangement.spacedBy(8.dp), - modifier = - Modifier - .width(dimens.maxReaderWidth), - ) { - withTextStyle(NestedTextStyle.CAPTION) { - appendTextChildren( - element.childNodes(), - baseUrl = baseUrl, - onLinkClick = onLinkClick, - keyHolder = keyHolder, - ) - } - render() - } - } - } - } - } else if (this is EagerComposer) { - appendTextChildren( - element.childNodes(), - baseUrl = baseUrl, - onLinkClick = onLinkClick, - keyHolder = keyHolder, - ) - } - } - - "img" -> { - appendImage(onLinkClick = onLinkClick) { onClick -> - val dimens = LocalDimens.current - Column( - verticalArrangement = Arrangement.spacedBy(8.dp), - modifier = - Modifier - .width(dimens.maxReaderWidth), - ) { - renderImage( - baseUrl = baseUrl, - onClick = onClick, - element = element, - ) - } - } - } - - "ul" -> { - element.children() - .filter { it.tagName() == "li" } - .forEach { listItem -> - withParagraph { - // no break space - append("•\u00A0") - appendTextChildren( - listItem.childNodes(), - baseUrl = baseUrl, - onLinkClick = onLinkClick, - keyHolder = keyHolder, - ) - } - } - } - - "ol" -> { - element.children() - .filter { it.tagName() == "li" } - .forEachIndexed { i, listItem -> - withParagraph { - // no break space - append("${i + 1}.\u00A0") - appendTextChildren( - listItem.childNodes(), - baseUrl = baseUrl, - onLinkClick = onLinkClick, - keyHolder = keyHolder, - ) - } - } - } - - "table" -> { - if (this is LazyListComposer) { - appendTable( - baseUrl = baseUrl, - keyHolder = keyHolder, - onLinkClick = onLinkClick, - element = element, - ) - } - } - - "iframe" -> { - val video: Video? = getVideo(element.attr("abs:src")) - - if (video != null) { - appendImage(onLinkClick = onLinkClick) { - val dimens = LocalDimens.current - Column( - modifier = - Modifier - .width(dimens.maxReaderWidth), - ) { - DisableSelection { - BoxWithConstraints( - modifier = Modifier.fillMaxWidth(), - ) { - val imageWidth by rememberMaxImageWidth() - AsyncImage( - model = - ImageRequest.Builder(LocalContext.current) - .placeholder(R.drawable.youtube_icon) - .error(R.drawable.youtube_icon) - .scale(Scale.FIT) - .size(imageWidth) - .precision(Precision.INEXACT) - .build(), - contentDescription = stringResource(R.string.touch_to_play_video), - contentScale = - if (dimens.hasImageAspectRatioInReader) { - ContentScale.Fit - } else { - ContentScale.FillWidth - }, - modifier = - Modifier - .clickable { - onLinkClick(video.link) - } - .fillMaxWidth() - .run { - dimens.imageAspectRatioInReader?.let { ratio -> - aspectRatio(ratio) - } ?: this - }, - ) - } - } - - Spacer(modifier = Modifier.height(16.dp)) - - ProvideScaledText( - MaterialTheme.typography.labelMedium.merge( - TextStyle(color = MaterialTheme.colorScheme.onBackground), - ), - ) { - val interactionSource = - remember { MutableInteractionSource() } - Text( - text = stringResource(R.string.touch_to_play_video), - modifier = - Modifier - .fillMaxWidth() - .indication( - interactionSource, - LocalIndication.current, - ) - .focusableInNonTouchMode(interactionSource = interactionSource), - ) - } - } - } - } - } - - "rt", "rp" -> { - // Ruby text elements. Not rendering them might be better than not - // handling them well - } - - "video" -> { - // not implemented yet. remember to disable selection - } - - else -> { - appendTextChildren( - nodes = element.childNodes(), - preFormatted = preFormatted, - baseUrl = baseUrl, - onLinkClick = onLinkClick, - keyHolder = keyHolder, - ) - } - } - } - } - - node = node.nextSibling() - } -} - -@Suppress("UnusedReceiverParameter") -@Composable -private fun ColumnScope.renderImage( - baseUrl: String, - onClick: (() -> Unit)?, - element: Element, -) { - val dimens = LocalDimens.current - - val imageCandidates by remember { - derivedStateOf { - getImageSource(baseUrl, element) - } - } - - if (imageCandidates.notHasImage) { - // No image, no need to render - return - } - - // Some sites are silly and insert formatting in alt text - val alt by remember { - derivedStateOf { - stripHtml(element.attr("alt") ?: "") - } - } - - DisableSelection { - BoxWithConstraints( - contentAlignment = Alignment.Center, - modifier = - Modifier - .clip(RectangleShape) - .clickable( - enabled = onClick != null, - ) { - onClick?.invoke() - } - .fillMaxWidth(), - ) { - val maxImageWidth by rememberMaxImageWidth() - val pixelDensity = LocalDensity.current.density - val bestImage by remember { - derivedStateOf { - imageCandidates.getBestImageForMaxSize( - pixelDensity = pixelDensity, - maxWidth = maxImageWidth, - ) - } - } - if (bestImage is NoImageCandidate) { - // No image, no need to render - return@BoxWithConstraints - } - val imageWidth: Int = - remember(bestImage) { - when (bestImage) { - is ImageCandidateFromSetWithPixelDensity -> maxImageWidth - is ImageCandidateFromSetWithWidth -> (bestImage as ImageCandidateFromSetWithWidth).width - is ImageCandidateUnknownSize -> maxImageWidth - is ImageCandidateWithSize -> (bestImage as ImageCandidateWithSize).width - // Will never happen - NoImageCandidate -> maxImageWidth - } - } - val imageHeight: Int? = - remember(bestImage) { - when (bestImage) { - is ImageCandidateWithSize -> (bestImage as ImageCandidateWithSize).height - else -> null - } - } - - WithTooltipIfNotBlank(tooltip = alt) { - val contentScale = - remember(pixelDensity, dimens.hasImageAspectRatioInReader) { - if (dimens.hasImageAspectRatioInReader) { - RestrainedFitScaling(pixelDensity) - } else { - RestrainedFillWidthScaling(pixelDensity) - } - } - - AsyncImage( - model = - ImageRequest.Builder(LocalContext.current) - .data(bestImage.url) - .scale(Scale.FIT) - // DO NOT use the actualSize parameter here - .size(Size(imageWidth, imageHeight ?: imageWidth)) - // If image is larger than requested size, scale down - // But if image is smaller, don't scale up - // Note that this is the pixels, not how it is scaled inside the ImageView - .precision(Precision.INEXACT) - .build(), - contentDescription = alt, - placeholder = - rememberTintedVectorPainter( - Icons.Outlined.Terrain, - ), - error = rememberTintedVectorPainter(Icons.Outlined.ErrorOutline), - contentScale = contentScale, - modifier = - Modifier - .widthIn(max = maxWidth) - .fillMaxWidth(), -// .run { -// // This looks awful for small images -// dimens.imageAspectRatioInReader?.let { ratio -> -// aspectRatio(ratio) -// } ?: this -// }, - ) - } - } - } - - // Figure has own caption so don't use alt text as caption there - val notFigureAncestor by remember { - derivedStateOf { - (element.notAncestorOf("figure")) - } - } - if (notFigureAncestor) { - if (alt.isNotBlank()) { - ProvideScaledText( - MaterialTheme.typography.labelMedium.merge( - TextStyle(color = MaterialTheme.colorScheme.onBackground), - ), - ) { - val interactionSource = remember { MutableInteractionSource() } - Text( - alt, - modifier = - Modifier - .fillMaxWidth() - .indication(interactionSource, LocalIndication.current) - .focusableInNonTouchMode(interactionSource = interactionSource), - ) - } - } - } +fun Element.ancestors(predicate: (Element) -> Boolean): Sequence { + return ancestors().filter(predicate) } -private fun LazyListComposer.appendTable( - baseUrl: String, - keyHolder: ArticleItemKeyHolder, - onLinkClick: (String) -> Unit, - element: Element, -) { - emitParagraph() - - val imgDescendant = element.hasDescendant("img") - - if (imgDescendant) { - appendTextChildren( - element.childNodes(), - baseUrl = baseUrl, - onLinkClick = onLinkClick, - keyHolder = keyHolder, - ) - } else { - item(keyHolder) { - val composer = - EagerComposer { paragraphBuilder, textStyler -> - ParagraphText( - paragraphBuilder = paragraphBuilder, - textStyler = textStyler, - modifier = Modifier, - onLinkClick = onLinkClick, - ) - } - with(composer) { - tableColFirst( - baseUrl = baseUrl, - onLinkClick = onLinkClick, - element = element, - keyHolder = keyHolder, - ) - } - } - } -} - -@Composable -private fun EagerComposer.tableColFirst( - baseUrl: String, - onLinkClick: (String) -> Unit, - keyHolder: ArticleItemKeyHolder, - element: Element, -) { - val rowCount by remember { - derivedStateOf { - try { - element.descendants("tr").count() - } catch (t: Throwable) { - 0 - } - } - } - val colCount by remember { - derivedStateOf { - try { - element.descendants("tr") - .map { row -> - row.descendants() - .filter { - it.tagName() in setOf("th", "td") - }.count() - }.maxOrNull() ?: 0 - } catch (t: Throwable) { - 0 - } - } - } - - /* - In this order: - optionally a caption element (containing text children for instance), - followed by zero or more colgroup elements, - followed optionally by a thead element, - followed by either zero or more tbody elements - or one or more tr elements, - followed optionally by a tfoot element - */ - val dimens = LocalDimens.current - Column( - verticalArrangement = Arrangement.spacedBy(4.dp), - modifier = - Modifier - .width(dimens.maxReaderWidth), - ) { - key(element, baseUrl, onLinkClick) { - element.children() - .filter { it.tagName() == "caption" } - .forEach { - withTextStyle(NestedTextStyle.CAPTION) { - appendTextChildren( - it.childNodes(), - baseUrl = baseUrl, - onLinkClick = onLinkClick, - keyHolder = keyHolder, - ) - } - render() - } - } - - val rowData by remember { - derivedStateOf { - element.children() - .filter { - it.tagName() in - setOf( - "thead", - "tbody", - "tfoot", - ) - } - .sortedBy { - when (it.tagName()) { - "thead" -> 0 - "tbody" -> 1 - "tfoot" -> 10 - else -> 2 - } - } - .flatMap { - it.children() - .filter { child -> child.tagName() == "tr" } - .map { child -> - it.tagName() to child - } - } - } - } - - key(rowCount, colCount, rowData, baseUrl, onLinkClick) { - if (rowCount > 0 && colCount > 0) { - LazyRow( - horizontalArrangement = Arrangement.spacedBy(32.dp), - modifier = - Modifier - .horizontalScroll(rememberScrollState()) - .width(dimens.maxReaderWidth), - ) { - items( - count = colCount, - ) { colIndex -> - Column( - verticalArrangement = Arrangement.spacedBy(4.dp), - modifier = Modifier, - ) { - for (rowIndex in 0 until rowCount) { - val (section, rowElement) = rowData.getOrNull(rowIndex) ?: break - var emptyCell = false - Surface( - tonalElevation = - when (section) { - "thead" -> 3.dp - "tbody" -> 0.dp - "tfoot" -> 1.dp - else -> 0.dp - }, - ) { - rowElement.children() - .filter { it.tagName() in setOf("th", "td") } - .elementAtOrNullWithSpans(colIndex) - ?.let { colElement -> - withParagraph { - withStyle( - if (colElement.tagName() == "th") { - SpanStyle(fontWeight = FontWeight.Bold) - } else { - null - }, - ) { - appendTextChildren( - colElement.childNodes(), - baseUrl = baseUrl, - onLinkClick = onLinkClick, - keyHolder = keyHolder, - ) - } - } - } - emptyCell = !render() - } - if (emptyCell) { - // An empty cell looks better if it has some height - but don't want - // the surface because having one space wide surface is weird - append(' ') - render() - } - } - } - } - } - } - } - } -} - -// Just ensures that columns coming after a spanned entry ends up in the right column -fun Iterable.elementAtOrNullWithSpans(index: Int): Element? { - var currentColumn = 0 - forEach { - if (currentColumn > index) { - // Span over this column - return null - } - if (currentColumn == index) { - return it - } - val spans = it.attr("colspan") ?: "1" - currentColumn += - when (val spanCount = spans.toIntOrNull()) { - null, 1 -> (spanCount ?: 1) - 0 -> return null // Firefox special - spans to end - else -> spanCount.coerceAtLeast(1) - } - } - return null -} - -private fun Element.descendants(tagName: String): Sequence { - return descendants().filter { it.tagName() == tagName } -} - -private fun Element.descendants(): Sequence { +private fun Element.ancestors(): Sequence { return sequence { - children().forEach { - recursiveSequence(it) - } - } -} + var current: Element? = this@ancestors.parent() -private suspend fun SequenceScope.recursiveSequence(element: Element) { - yield(element) - - element.children().forEach { - recursiveSequence(it) - } -} - -private fun Element.hasDescendant(tagName: String): Boolean { - return descendants(tagName).any() -} - -private fun Element.firstDescendant(tagName: String): Element? { - return descendants(tagName).firstOrNull() -} - -private fun Element.firstBestDescendantImg(baseUrl: String): Element? { - return descendants("img") - .firstOrNull { element -> - ImageCandidates( - baseUrl = baseUrl, - srcSet = element.attr("srcset") ?: "", - absSrc = element.attr("abs:src") ?: "", - dataImgUrl = element.attr("data-img-src") ?: "", - width = element.attr("width")?.toIntOrNull(), - height = element.attr("height")?.toIntOrNull(), - ).hasImage + while (current != null) { + yield(current) + current = current.parent() } - // Return first just to show error image instead then - ?: firstDescendant("img") -} - -private fun Element.notAncestorOf(tagName: String): Boolean { - var current: Element? = this - - while (current != null) { - val parent = current.parent() - - current = - when { - parent == null || parent.tagName() == "#root" -> { - null - } - - parent.tagName() == tagName -> { - return false - } - - else -> { - parent - } - } } - - return true } -private enum class NestedTextStyle : TextStyler { - CAPTION { - @Composable - override fun textStyle() = - MaterialTheme.typography.labelMedium.merge( - TextStyle(color = MaterialTheme.colorScheme.onBackground), - ) - }, -} - -private fun String.asFontFamily(): FontFamily? = +fun String.asFontFamily(): FontFamily? = when (this.lowercase()) { "monospace" -> FontFamily.Monospace "serif" -> FontFamily.Serif @@ -1209,34 +37,6 @@ private fun String.asFontFamily(): FontFamily? = else -> null } -@Preview -@Composable -private fun TestIt() { - val html = - """ -

In Gimp you go to Image in the top menu bar and select Mode followed by Indexed. Now you see a popup where you can select the number of colors for a generated optimum palette.

You’ll have to experiment a little because it will depend on your image.

I used this approach to shrink the size of the cover image in the_zopfli post from a 37KB (JPG) to just 15KB (PNG, all PNG sizes listed include Zopfli compression btw).

Straight JPG to PNG conversion: 124KB

PNG version RGB colors

First off, I exported the JPG file as a PNG file. This PNG file had a whopping 124KB! Clearly there was some bloat being stored.

256 colors: 40KB

Reducing from RGB to only 256 colors has no visible effect to my eyes.

256 colors

128 colors: 34KB

Still no difference.

128 colors

64 colors: 25KB

You can start to see some artifacting in the shadow behind the text.

64 colors

32 colors: 15KB

In my opinion this is the sweet spot. The shadow artifacting is barely noticable but the size is significantly reduced.

32 colors

16 colors: 11KB

Clear artifacting in the text shadow and the yellow (fire?) in the background has developed an outline.

16 colors

8 colors: 7.3KB

The broom has shifted in color from a clear brown to almost grey. Text shadow is just a grey blob at this point. Even clearer outline developed on the yellow background.

8 colors

4 colors: 4.3KB

Interestingly enough, I think 4 colors looks better than 8 colors. The outline in the background has disappeared because there’s not enough color spectrum to render it. The broom is now black and filled areas tend to get a white separator to the outlines.

4 colors

2 colors: 2.4KB

Well, at least the silhouette is well defined at this point I guess.

2 colors


Other posts in the Migrating from Ghost to Hugo series:

- """.trimIndent() - - FeederTheme { - Surface { - html.byteInputStream().use { stream -> - LazyColumn { - htmlFormattedText( - inputStream = stream, - baseUrl = "https://cowboyprogrammer.org", - keyHolder = - object : ArticleItemKeyHolder { - override fun getAndIncrementKey(): Long { - return Random.nextLong() - } - }, - ) {} - } - } - } - } -} - @Composable fun BoxWithConstraintsScope.rememberMaxImageWidth() = with(LocalDensity.current) { @@ -1247,177 +47,6 @@ fun BoxWithConstraintsScope.rememberMaxImageWidth() = } } -/** - * Gets the url to the image in the tag - could be from srcset or from src - */ -internal fun getImageSource( - baseUrl: String, - element: Element, -) = ImageCandidates( - baseUrl = baseUrl, - srcSet = element.attr("srcset") ?: "", - absSrc = element.attr("abs:src") ?: "", - dataImgUrl = element.attr("data-img-url") ?: "", - width = element.attr("width").toIntOrNull(), - height = element.attr("height").toIntOrNull(), -) - -internal class ImageCandidates( - val baseUrl: String, - val srcSet: String, - val absSrc: String, - val dataImgUrl: String, - val width: Int?, - val height: Int?, -) { - // Explicitly width/height = 0 means no image - val hasImage: Boolean = width != 0 && height != 0 && (srcSet.isNotBlank() || absSrc.isNotBlank() || dataImgUrl.isNotBlank()) - val notHasImage: Boolean = !hasImage - - fun getBestImageForMaxSize( - maxWidth: Int, - pixelDensity: Float, - ): ImageCandidate { - try { - val setCandidate = - srcSet.splitToSequence(", ") - .map { it.trim() } - .map { it.split(spaceRegex).take(2).map { x -> x.trim() } } - .fold(Float.MAX_VALUE to NoImageCandidate) { acc: Pair, candidate -> - if (candidate.first().isBlank()) { - return@fold acc - } - val (candidateSize, imageCandidate) = - if (candidate.size == 1) { - // Assume it corresponds to 1x pixel density - (1.0f / pixelDensity) to - ImageCandidateFromSetWithPixelDensity( - url = StringUtil.resolve(baseUrl, candidate.first()), - pixelDensity = 1.0f, - ) - } else { - val descriptor = candidate.last() - when { - descriptor.endsWith("w", ignoreCase = true) -> { - val width = descriptor.substringBefore("w").toFloat() - if (width < 1.0f) { - return@fold acc - } - - val ratio = width / maxWidth.toFloat() - - ratio to - ImageCandidateFromSetWithWidth( - url = StringUtil.resolve(baseUrl, candidate.first()), - width = width.toInt(), - ) - } - - descriptor.endsWith("x", ignoreCase = true) -> { - val density = descriptor.substringBefore("x").toFloat() - val ratio = density / pixelDensity - - ratio to - ImageCandidateFromSetWithPixelDensity( - url = StringUtil.resolve(baseUrl, candidate.first()), - pixelDensity = density, - ) - } - - else -> { - return@fold acc - } - } - } - - // Find the image with the size closest to the desired size - if (abs(candidateSize - 1.0f) < abs(acc.first - 1.0f)) { - candidateSize to imageCandidate - } else { - acc - } - } - .second - - if (setCandidate !is NoImageCandidate) { - return setCandidate - } - - val dataImgUrlCandidate = - dataImgUrl.takeIf { it.isNotBlank() }?.let { - val url = StringUtil.resolve(baseUrl, it) - if (width != null && height != null) { - ImageCandidateWithSize( - url = url, - width = width, - height = height, - ) - } else { - ImageCandidateUnknownSize( - url = url, - ) - } - } ?: NoImageCandidate - - if (dataImgUrlCandidate !is NoImageCandidate) { - return dataImgUrlCandidate - } - - return absSrc.takeIf { it.isNotBlank() }?.let { - val url = StringUtil.resolve(baseUrl, it) - if (width != null && height != null) { - ImageCandidateWithSize( - url = url, - width = width, - height = height, - ) - } else { - ImageCandidateUnknownSize( - url = url, - ) - } - } ?: NoImageCandidate - } catch (_: Throwable) { - return NoImageCandidate - } - } - - override fun toString(): String { - return "ImageCandidates(srcSet=$srcSet, src=$absSrc)" - } -} - -sealed class ImageCandidate { - abstract val url: String -} - -data object NoImageCandidate : ImageCandidate() { - override val url: String - get() = "" -} - -data class ImageCandidateUnknownSize( - override val url: String, -) : ImageCandidate() - -data class ImageCandidateWithSize( - override val url: String, - val width: Int, - val height: Int, -) : ImageCandidate() - -data class ImageCandidateFromSetWithWidth( - override val url: String, - val width: Int, -) : ImageCandidate() - -data class ImageCandidateFromSetWithPixelDensity( - override val url: String, - val pixelDensity: Float, -) : ImageCandidate() - -private val spaceRegex = Regex("\\s+") - /** * Can't use JSoup's text() method because that strips invisible characters * such as ZWNJ which are crucial for several languages. @@ -1470,7 +99,7 @@ private const val FORM_FEED = 12.toChar() // 160 is   (non-breaking space). Not in the spec but expected. private const val NON_BREAKING_SPACE = 160.toChar() -private fun isCollapsableWhiteSpace(c: String) = c.firstOrNull()?.let { isCollapsableWhiteSpace(it) } ?: false +internal fun isCollapsableWhiteSpace(c: String) = c.firstOrNull()?.let { isCollapsableWhiteSpace(it) } ?: false private fun isCollapsableWhiteSpace(c: Char) = c == SPACE || c == TAB || c == LINE_FEED || c == CARRIAGE_RETURN || c == FORM_FEED || c == NON_BREAKING_SPACE diff --git a/app/src/main/java/com/nononsenseapps/feeder/ui/compose/text/LazyListComposer.kt b/app/src/main/java/com/nononsenseapps/feeder/ui/compose/text/LazyListComposer.kt deleted file mode 100644 index 2c48d37c1..000000000 --- a/app/src/main/java/com/nononsenseapps/feeder/ui/compose/text/LazyListComposer.kt +++ /dev/null @@ -1,85 +0,0 @@ -package com.nononsenseapps.feeder.ui.compose.text - -import androidx.compose.foundation.lazy.LazyListScope -import androidx.compose.runtime.Composable -import com.nononsenseapps.feeder.ui.compose.feedarticle.ArticleItemKeyHolder - -class LazyListComposer( - private val lazyListScope: LazyListScope, - private val keyHolder: ArticleItemKeyHolder, - private val paragraphEmitter: @Composable (AnnotatedParagraphStringBuilder, TextStyler?) -> Unit, -) : HtmlComposer() { - override fun emitParagraph(): Boolean { - // List items emit dots and non-breaking space. Don't newline after that - if (builder.isEmpty() || builder.endsWithNonBreakingSpace) { - // Nothing to emit, and nothing to reset - return false - } - - // Important that we reference the correct builder in the lambda - reset will create a new - // builder and the lambda will run after that - val actualBuilder = builder - val actualTextStyle = textStyleStack.lastOrNull() - - item(keyHolder = keyHolder) { - paragraphEmitter(actualBuilder, actualTextStyle) - } - resetAfterEmit() - return true - } - - override fun appendImage( - link: String?, - onLinkClick: (String) -> Unit, - block: @Composable (() -> Unit) -> Unit, - ) { - emitParagraph() - - val url = link ?: findClosestLink() - val onClick: (() -> Unit) = - when { - url?.isNotBlank() == true -> { - { - onLinkClick(url) - } - } - else -> { - {} - } - } - - item(keyHolder = keyHolder) { - block(onClick) - } - } - - /** - * Key is necessary or when you switch between default and full text - the initial items - * will have the same index and will not recompose. - */ - fun item( - keyHolder: ArticleItemKeyHolder, - block: @Composable () -> Unit, - ) { - lazyListScope.item(key = keyHolder.getAndIncrementKey()) { - block() - } - } - - private fun resetAfterEmit() { - builder = AnnotatedParagraphStringBuilder() - - for (span in spanStack) { - when (span) { - is SpanWithStyle -> builder.pushStyle(span.spanStyle) - is SpanWithAnnotation -> - builder.pushStringAnnotation( - tag = span.tag, - annotation = span.annotation, - ) - is SpanWithComposableStyle -> builder.pushComposableStyle(span.spanStyle) - is SpanWithVerbatim -> builder.pushVerbatimTtsAnnotation(span.verbatim) - } - } - } -} diff --git a/app/src/main/java/com/nononsenseapps/feeder/ui/compose/theme/Dimensions.kt b/app/src/main/java/com/nononsenseapps/feeder/ui/compose/theme/Dimensions.kt index 7146c6e79..925b1d8e8 100644 --- a/app/src/main/java/com/nononsenseapps/feeder/ui/compose/theme/Dimensions.kt +++ b/app/src/main/java/com/nononsenseapps/feeder/ui/compose/theme/Dimensions.kt @@ -1,5 +1,7 @@ package com.nononsenseapps.feeder.ui.compose.theme +import androidx.compose.material3.windowsizeclass.WindowHeightSizeClass +import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.Immutable @@ -7,7 +9,9 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.coerceAtMost import androidx.compose.ui.unit.dp +import com.nononsenseapps.feeder.ui.compose.utils.LocalWindowSize @Immutable class Dimensions( @@ -56,8 +60,8 @@ val Dimensions.hasImageAspectRatioInReader: Boolean val phoneDimensions = Dimensions( - maxContentWidth = 840.dp, - maxReaderWidth = 840.dp, + maxContentWidth = 600.dp, + maxReaderWidth = 600.dp, imageAspectRatioInReader = null, navIconMargin = 16.dp, margin = 16.dp, @@ -66,19 +70,19 @@ val phoneDimensions = feedScreenColumns = 1, ) -fun tabletDimensions(screenWidthDp: Int): Dimensions { +fun tabletDimensions(windowWidthDp: Dp): Dimensions { // Items look good at around 300dp width. Account for 32dp margin at the sides, and the gutters // 3 columns: 3*300 + 4*32 = 1028 val columns = when { - screenWidthDp > 1360 -> 4 - screenWidthDp > 1028 -> 3 + windowWidthDp > 1360.dp -> 4 + windowWidthDp > 1028.dp -> 3 else -> 2 } return Dimensions( - maxContentWidth = 840.dp, - maxReaderWidth = 640.dp, - imageAspectRatioInReader = 16.0f / 9.0f, + maxContentWidth = 840.dp.coerceAtMost(windowWidthDp), + maxReaderWidth = 640.dp.coerceAtMost(windowWidthDp), + imageAspectRatioInReader = null, navIconMargin = 32.dp, margin = 32.dp, gutter = 32.dp, @@ -106,14 +110,31 @@ val LocalDimens = @Composable fun ProvideDimens(content: @Composable () -> Unit) { + val windowSizeClass = LocalWindowSize.current val config = LocalConfiguration.current + val dimensionSet = remember { - when { - config.screenWidthDp == 960 && config.screenHeightDp == 540 -> tvDimensions - config.smallestScreenWidthDp >= 600 -> tabletDimensions(config.screenWidthDp) - else -> phoneDimensions + if (config.screenWidthDp == 960 && config.screenHeightDp == 540) { + // TV dimensions are special case + tvDimensions + } else { + when (val widthClass = windowSizeClass.widthSizeClass) { + WindowWidthSizeClass.Compact -> phoneDimensions + else -> { + when (windowSizeClass.heightSizeClass) { + WindowHeightSizeClass.Compact -> phoneDimensions + else -> + if (widthClass == WindowWidthSizeClass.Medium) { + tabletDimensions(600.dp) + } else { + tabletDimensions(840.dp) + } + } + } + } } } + CompositionLocalProvider(LocalDimens provides dimensionSet, content = content) } diff --git a/app/src/main/java/com/nononsenseapps/feeder/ui/compose/theme/Typography.kt b/app/src/main/java/com/nononsenseapps/feeder/ui/compose/theme/Typography.kt index ddc334273..3b519147d 100644 --- a/app/src/main/java/com/nononsenseapps/feeder/ui/compose/theme/Typography.kt +++ b/app/src/main/java/com/nononsenseapps/feeder/ui/compose/theme/Typography.kt @@ -124,6 +124,9 @@ fun CodeBlockStyle(): TextStyle = @Composable fun CodeBlockBackground(): Color = MaterialTheme.colorScheme.surfaceVariant +@Composable +fun OnCodeBlockBackground(): Color = MaterialTheme.colorScheme.onSurfaceVariant + @Composable fun BlockQuoteStyle(): SpanStyle = MaterialTheme.typography.bodyLarge.toSpanStyle().merge( diff --git a/app/src/main/java/com/nononsenseapps/feeder/ui/compose/utils/ComposeProviders.kt b/app/src/main/java/com/nononsenseapps/feeder/ui/compose/utils/ComposeProviders.kt index ba32e5894..d3a30ee25 100644 --- a/app/src/main/java/com/nononsenseapps/feeder/ui/compose/utils/ComposeProviders.kt +++ b/app/src/main/java/com/nononsenseapps/feeder/ui/compose/utils/ComposeProviders.kt @@ -26,12 +26,12 @@ fun DIAwareComponentActivity.withAllProviders(content: @Composable () -> Unit) { val dynamicColors by viewModel.dynamicColors.collectAsStateWithLifecycle() val textScale by viewModel.textScale.collectAsStateWithLifecycle() withFoldableHinge { - FeederTheme( - currentTheme = currentTheme, - darkThemePreference = darkThemePreference, - dynamicColors = dynamicColors, - ) { - withWindowSize { + withWindowSize { + FeederTheme( + currentTheme = currentTheme, + darkThemePreference = darkThemePreference, + dynamicColors = dynamicColors, + ) { ProvideFontScale(fontScale = textScale) { WithFeederTextToolbar(content) } @@ -48,16 +48,16 @@ fun WithAllPreviewProviders( currentTheme: ThemeOptions = ThemeOptions.DAY, content: @Composable () -> Unit, ) { - FeederTheme(currentTheme = currentTheme) { - val dm = LocalContext.current.resources.displayMetrics - val dpSize = - with(LocalDensity.current) { - DpSize( - dm.widthPixels.toDp(), - dm.heightPixels.toDp(), - ) - } - WithPreviewWindowSize(WindowSizeClass.calculateFromSize(dpSize)) { + val dm = LocalContext.current.resources.displayMetrics + val dpSize = + with(LocalDensity.current) { + DpSize( + dm.widthPixels.toDp(), + dm.heightPixels.toDp(), + ) + } + WithPreviewWindowSize(WindowSizeClass.calculateFromSize(dpSize)) { + FeederTheme(currentTheme = currentTheme) { Surface { content() } diff --git a/app/src/main/java/com/nononsenseapps/feeder/ui/compose/utils/ProvideScaledText.kt b/app/src/main/java/com/nononsenseapps/feeder/ui/compose/utils/ProvideScaledText.kt index bb2b6fbe8..634a1d8b8 100644 --- a/app/src/main/java/com/nononsenseapps/feeder/ui/compose/utils/ProvideScaledText.kt +++ b/app/src/main/java/com/nononsenseapps/feeder/ui/compose/utils/ProvideScaledText.kt @@ -1,5 +1,6 @@ package com.nononsenseapps.feeder.ui.compose.utils +import androidx.compose.material3.LocalTextStyle import androidx.compose.material3.ProvideTextStyle import androidx.compose.runtime.Composable import androidx.compose.ui.text.TextStyle @@ -7,7 +8,7 @@ import com.nononsenseapps.feeder.ui.compose.theme.LocalTypographySettings @Composable fun ProvideScaledText( - style: TextStyle, + style: TextStyle = LocalTextStyle.current, content: @Composable () -> Unit, ) { val typographySettings = LocalTypographySettings.current diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index cf27100ff..d4c166951 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -261,4 +261,5 @@ Close menu Skip duplicate articles Articles with links or titles identical to existing articles are ignored + Touch to play audio diff --git a/app/src/test/java/com/nononsenseapps/feeder/model/html/HtmlLinearizerTest.kt b/app/src/test/java/com/nononsenseapps/feeder/model/html/HtmlLinearizerTest.kt new file mode 100644 index 000000000..fdbac7669 --- /dev/null +++ b/app/src/test/java/com/nononsenseapps/feeder/model/html/HtmlLinearizerTest.kt @@ -0,0 +1,1393 @@ +package com.nononsenseapps.feeder.model.html + +import com.nononsenseapps.feeder.ui.compose.html.toTableData +import org.junit.Before +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class HtmlLinearizerTest { + private lateinit var linearizer: HtmlLinearizer + + @Before + fun setUp() { + linearizer = HtmlLinearizer() + } + + @Test + fun `should return empty list when input is empty`() { + val html = "" + val baseUrl = "https://example.com" + + val result = linearizer.linearize(html, baseUrl).elements + + assertEquals(emptyList(), result) + } + + @Test + fun `should return single LinearText when input is simple text`() { + val html = "Hello, world!" + val baseUrl = "https://example.com" + + val result = linearizer.linearize(html, baseUrl).elements + + assertEquals(1, result.size) + assertEquals(LinearText("Hello, world!", LinearTextBlockStyle.TEXT), result[0]) + } + + @Test + fun `should return annotations with bold, italic, and underline`() { + val html = "Hello, world!" + val baseUrl = "https://example.com" + + val result = linearizer.linearize(html, baseUrl).elements + + assertEquals(1, result.size) + assertEquals( + LinearText( + "Hello, world!", + LinearTextBlockStyle.TEXT, + LinearTextAnnotation(LinearTextAnnotationBold, 0, 12), + LinearTextAnnotation(LinearTextAnnotationItalic, 0, 12), + LinearTextAnnotation(LinearTextAnnotationUnderline, 0, 12), + ), + result[0], + ) + } + + @Test + fun `should return annotations with bold, italic, and underline interleaving`() { + val html = "Hello, world!" + val baseUrl = "https://example.com" + + val result = linearizer.linearize(html, baseUrl).elements + + assertEquals(1, result.size) + assertEquals( + LinearText( + "Hello, world!", + LinearTextBlockStyle.TEXT, + LinearTextAnnotation(LinearTextAnnotationBold, 0, 9), + LinearTextAnnotation(LinearTextAnnotationItalic, 0, 4), + LinearTextAnnotation(LinearTextAnnotationUnderline, 0, 3), + ), + result[0], + ) + } + + @Test + fun `should return own item for header`() { + // separate items for the paragraph and the H1 + val html = "

Header 1

Hello, world!" + val baseUrl = "https://example.com" + + val result = linearizer.linearize(html, baseUrl).elements + + assertEquals(2, result.size) + assertEquals(LinearText("Header 1", LinearTextBlockStyle.TEXT, LinearTextAnnotation(LinearTextAnnotationH1, 0, 7)), result[0]) + assertEquals(LinearText("Hello, world!", LinearTextBlockStyle.TEXT), result[1]) + } + + @Test + fun `should return single item for nested divs`() { + val html = "
Hello, world!
" + val baseUrl = "https://example.com" + + val result = linearizer.linearize(html, baseUrl).elements + + assertEquals(1, result.size) + assertEquals(LinearText("Hello, world!", LinearTextBlockStyle.TEXT), result[0]) + } + + @Test + fun `should return ordered LinearList for ol`() { + val html = "
  1. Item 1
  2. Item 2
" + val baseUrl = "https://example.com" + + val result = linearizer.linearize(html, baseUrl).elements + + assertEquals(1, result.size, "Expected one item: $result") + assertEquals( + LinearList( + ordered = true, + items = + listOf( + LinearListItem( + LinearText("Item 1", LinearTextBlockStyle.TEXT), + ), + LinearListItem( + LinearText("Item 2", LinearTextBlockStyle.TEXT), + ), + ), + ), + result[0], + ) + } + + @Test + fun `should return unordered LinearList for ul`() { + val html = "
  • Item 1
  • Item 2
" + val baseUrl = "https://example.com" + + val result = linearizer.linearize(html, baseUrl).elements + + assertEquals(1, result.size, "Expected one item: $result") + assertEquals( + LinearList( + ordered = false, + items = + listOf( + LinearListItem( + LinearText("Item 1", LinearTextBlockStyle.TEXT), + ), + LinearListItem( + LinearText("Item 2", LinearTextBlockStyle.TEXT), + ), + ), + ), + result[0], + ) + } + + @Test + fun `surrounding span is preserved with list in middle`() { + val html = "Before it
  • Item 1
  • Item 22
After
" + val baseUrl = "https://example.com" + + val result = linearizer.linearize(html, baseUrl).elements + + assertEquals(3, result.size, "Expected three items: $result") + assertEquals( + LinearText("Before it", LinearTextBlockStyle.TEXT, LinearTextAnnotation(LinearTextAnnotationBold, 0, 8)), + result[0], + ) + assertEquals( + LinearList( + ordered = false, + items = + listOf( + LinearListItem( + LinearText("Item 1", LinearTextBlockStyle.TEXT, LinearTextAnnotation(data = LinearTextAnnotationBold, start = 0, end = 5)), + ), + LinearListItem( + LinearText("Item 22", LinearTextBlockStyle.TEXT, LinearTextAnnotation(data = LinearTextAnnotationBold, start = 0, end = 6)), + ), + ), + ), + result[1], + ) + assertEquals( + LinearText("After", LinearTextBlockStyle.TEXT, LinearTextAnnotation(data = LinearTextAnnotationBold, start = 0, end = 4)), + result[2], + ) + } + + @Test + fun `simple image with alt text should return single Image`() { + val html = "\"Alt" + val baseUrl = "https://example.com" + + val result = linearizer.linearize(html, baseUrl).elements + + assertEquals(1, result.size, "Expected one item: $result") + assertEquals( + LinearImage( + sources = + listOf( + LinearImageSource(imgUri = "https://example.com/image.jpg", widthPx = null, heightPx = null, pixelDensity = null, screenWidth = null), + ), + caption = LinearText("Alt text", LinearTextBlockStyle.TEXT), + link = null, + ), + result[0], + ) + } + + @Test + fun `simple image with bold alt text should return no formatting`() { + val html = "\"<bBold text\"/>" + val baseUrl = "https://example.com" + + val result = linearizer.linearize(html, baseUrl).elements + + assertEquals(1, result.size, "Expected one item: $result") + assertEquals( + LinearImage( + sources = + listOf( + LinearImageSource(imgUri = "https://example.com/image.jpg", widthPx = null, heightPx = null, pixelDensity = null, screenWidth = null), + ), + caption = LinearText("Bold text", LinearTextBlockStyle.TEXT), + link = null, + ), + result[0], + ) + } + + @Test + fun `simple image inside a link`() { + val html = "\"Alt" + val baseUrl = "https://example.com" + + val result = linearizer.linearize(html, baseUrl).elements + + assertEquals(1, result.size, "Expected one item: $result") + assertEquals( + LinearImage( + sources = + listOf( + LinearImageSource(imgUri = "https://example.com/image.jpg", widthPx = null, heightPx = null, pixelDensity = null, screenWidth = null), + ), + caption = LinearText("Alt text", LinearTextBlockStyle.TEXT), + link = "https://example.com/link", + ), + result[0], + ) + } + + @Test + fun `simple image with defined size`() { + val html = "" + val baseUrl = "https://example.com" + + val result = linearizer.linearize(html, baseUrl).elements + + assertEquals(1, result.size, "Expected one item: $result") + assertEquals( + LinearImage( + sources = + listOf( + LinearImageSource(imgUri = "https://example.com/image.jpg", widthPx = 100, heightPx = 200, pixelDensity = null, screenWidth = null), + ), + caption = null, + link = null, + ), + result[0], + ) + } + + @Test + fun `srcset image with pixel density and screenwidth versions available`() { + val html = + """ + + + + """.trimIndent() + val baseUrl = "https://example.com" + + val result = linearizer.linearize(html, baseUrl).elements + + assertEquals(1, result.size, "Expected one item: $result") + assertEquals( + LinearImage( + sources = + listOf( + LinearImageSource(imgUri = "https://example.com/image.jpg", widthPx = null, heightPx = null, screenWidth = null, pixelDensity = 1f), + LinearImageSource(imgUri = "https://example.com/image-2x.jpg", widthPx = null, heightPx = null, screenWidth = null, pixelDensity = 2f), + LinearImageSource(imgUri = "https://example.com/image-700w.jpg", widthPx = null, heightPx = null, screenWidth = 700, pixelDensity = null), + LinearImageSource(imgUri = "https://example.com/image-fallback.jpg", widthPx = null, heightPx = null, screenWidth = null, pixelDensity = null), + ), + caption = null, + link = null, + ), + result[0], + ) + } + + @Test + fun `simple image with dataImgUrl`() { + val html = "" + val baseUrl = "https://example.com" + + val result = linearizer.linearize(html, baseUrl).elements + + assertEquals(1, result.size, "Expected one item: $result") + assertEquals( + LinearImage( + sources = + listOf( + LinearImageSource(imgUri = "https://example.com/image.jpg", widthPx = null, heightPx = null, pixelDensity = null, screenWidth = null), + ), + caption = null, + link = null, + ), + result[0], + ) + } + + @Test + fun `simple image with relative url`() { + val html = "" + val baseUrl = "https://example.com" + + val result = linearizer.linearize(html, baseUrl).elements + + assertEquals(1, result.size, "Expected one item: $result") + assertEquals( + LinearImage( + sources = + listOf( + LinearImageSource(imgUri = "https://example.com/image.jpg", widthPx = null, heightPx = null, pixelDensity = null, screenWidth = null), + ), + caption = null, + link = null, + ), + result[0], + ) + } + + @Test + fun `simple figure image with figcaption`() { + val html = "
Alt text
" + val baseUrl = "https://example.com" + + val result = linearizer.linearize(html, baseUrl).elements + + assertEquals(1, result.size, "Expected one item: $result") + assertEquals( + LinearImage( + sources = + listOf( + LinearImageSource(imgUri = "https://example.com/image.jpg", widthPx = null, heightPx = null, pixelDensity = null, screenWidth = null), + ), + caption = LinearText("Alt text", LinearTextBlockStyle.TEXT, LinearTextAnnotation(LinearTextAnnotationBold, 4, 4)), + link = null, + ), + result[0], + ) + } + + @Test + fun `figure inside a link`() { + val html = + """ + +
Alt text
+
+ """.trimIndent() + val baseUrl = "https://example.com" + + val result = linearizer.linearize(html, baseUrl).elements + + assertEquals(1, result.size, "Expected one item: $result") + assertEquals( + LinearImage( + sources = + listOf( + LinearImageSource(imgUri = "https://example.com/image.jpg", widthPx = null, heightPx = null, pixelDensity = null, screenWidth = null), + ), + caption = LinearText("Alt text", LinearTextBlockStyle.TEXT, LinearTextAnnotation(data = LinearTextAnnotationLink("https://example.com/link"), start = 0, end = 7)), + link = "https://example.com/link", + ), + result[0], + ) + } + + @Test + fun `p in a blockquote does not add newlines at end and cite is null`() { + val html = "

Quote

" + val baseUrl = "https://example.com" + + val result = linearizer.linearize(html, baseUrl).elements + + assertEquals(1, result.size, "Expected one item: $result") + assertTrue(result[0] is LinearBlockQuote, "Expected LinearBlockQuote: $result") + assertEquals( + LinearBlockQuote( + cite = null, + content = listOf(LinearText("Quote", LinearTextBlockStyle.TEXT)), + ), + result[0], + ) + } + + @Test + fun `figure with two img tags one with srcset and one with dataImgUrl - only distinct results`() { + val html = + """ +
+ + +
+ """.trimIndent() + val baseUrl = "https://example.com" + + val result = linearizer.linearize(html, baseUrl).elements + + assertEquals(1, result.size, "Expected one item: $result") + assertEquals( + LinearImage( + sources = + listOf( + LinearImageSource(imgUri = "https://example.com/image.jpg", widthPx = null, heightPx = null, pixelDensity = 1f, screenWidth = null), + LinearImageSource(imgUri = "https://example.com/image-2x.jpg", widthPx = null, heightPx = null, pixelDensity = 2f, screenWidth = null), + ), + caption = null, + link = null, + ), + result[0], + ) + } + + @Test + fun `figure with two img tags one with srcset and one with dataImgUrl all urls different`() { + val html = + """ +
+ + +
+ """.trimIndent() + val baseUrl = "https://example.com" + + val result = linearizer.linearize(html, baseUrl).elements + + assertEquals(1, result.size, "Expected one item: $result") + assertEquals( + LinearImage( + sources = + listOf( + LinearImageSource(imgUri = "https://example.com/image.jpg", widthPx = null, heightPx = null, pixelDensity = 1f, screenWidth = null), + LinearImageSource(imgUri = "https://example.com/image-2x.jpg", widthPx = null, heightPx = null, pixelDensity = 2f, screenWidth = null), + LinearImageSource(imgUri = "https://example.com/image-3x.jpg", widthPx = null, heightPx = null, pixelDensity = null, screenWidth = null), + ), + caption = null, + link = null, + ), + result[0], + ) + } + + @Test + fun `pre block with code tag`() { + val html = "
\nCode\n  block\n
" + val baseUrl = "https://example.com" + + val result = linearizer.linearize(html, baseUrl).elements + + assertEquals(1, result.size, "Expected one item: $result") + assertEquals( + LinearText("\nCode\n block", LinearTextBlockStyle.CODE_BLOCK, LinearTextAnnotation(LinearTextAnnotationCode, 0, 12)), + result[0], + ) + } + + @Test + fun `pre block`() { + val html = "
\nCode\n  block\n
" + val baseUrl = "https://example.com" + + val result = linearizer.linearize(html, baseUrl).elements + + assertEquals(1, result.size, "Expected one item: $result") + assertEquals( + LinearText("\nCode\n block", LinearTextBlockStyle.PRE_FORMATTED), + result[0], + ) + } + + @Test + fun `pre block without code tag`() { + val html = "
Not a code block
" + val baseUrl = "https://example.com" + + val result = linearizer.linearize(html, baseUrl).elements + + assertEquals(1, result.size, "Expected one item: $result") + assertEquals( + LinearText("Not a code block", LinearTextBlockStyle.PRE_FORMATTED), + result[0], + ) + } + + @Test + fun `audio with no sources is ignored`() { + val html = "" + val baseUrl = "https://example.com" + + val result = linearizer.linearize(html, baseUrl).elements + + assertEquals(emptyList(), result) + } + + @Test + fun `audio with two sources`() { + val html = + """ + + + """.trimIndent() + val baseUrl = "https://example.com" + + val result = linearizer.linearize(html, baseUrl).elements + + assertEquals(1, result.size, "Expected one item: $result") + assertEquals( + LinearAudio( + sources = + listOf( + LinearAudioSource("https://example.com/audio.mp3", "audio/mpeg"), + LinearAudioSource("https://example.com/audio.ogg", "audio/ogg"), + ), + ), + result[0], + ) + } + + @Test + fun `video with no sources is ignored`() { + val html = "" + val baseUrl = "https://example.com" + + val result = linearizer.linearize(html, baseUrl).elements + + assertEquals(emptyList(), result) + } + + @Test + fun `video with two sources`() { + val html = + """ + + + """.trimIndent() + val baseUrl = "https://example.com" + + val result = linearizer.linearize(html, baseUrl).elements + + assertEquals(1, result.size, "Expected one item: $result") + assertEquals( + LinearVideo( + sources = + listOf( + LinearVideoSource("https://example.com/video.mp4", "https://example.com/video.mp4", null, null, null, "video/mp4"), + LinearVideoSource("https://example.com/video.webm", "https://example.com/video.webm", null, null, null, "video/webm"), + ), + ), + result[0], + ) + } + + @Test + fun `iframe with youtube video`() { + val html = "" + val baseUrl = "https://example.com" + + val result = linearizer.linearize(html, baseUrl).elements + + assertEquals(1, result.size, "Expected one item: $result") + assertEquals( + LinearVideo( + sources = + listOf( + LinearVideoSource( + "https://www.youtube.com/embed/cjxnVO9RpaQ", + "https://www.youtube.com/watch?v=cjxnVO9RpaQ", + "http://img.youtube.com/vi/cjxnVO9RpaQ/hqdefault.jpg", + 480, + 360, + null, + ), + ), + ), + result[0], + ) + } + + @Test + fun `table block 2x2`() { + val html = "
12
34
" + val baseUrl = "https://example.com" + + val result = linearizer.linearize(html, baseUrl).elements + + assertEquals(1, result.size, "Expected one item: $result") + assertEquals( + LinearTable.build { + newRow() + add(LinearTableCellItem(type = LinearTableCellItemType.HEADER, colSpan = 1, rowSpan = 1, content = listOf(LinearText("1", LinearTextBlockStyle.TEXT)))) + add(LinearTableCellItem(type = LinearTableCellItemType.DATA, colSpan = 1, rowSpan = 1, content = listOf(LinearText("2", LinearTextBlockStyle.TEXT)))) + newRow() + add(LinearTableCellItem(type = LinearTableCellItemType.DATA, colSpan = 1, rowSpan = 1, content = listOf(LinearText("3", LinearTextBlockStyle.TEXT)))) + add(LinearTableCellItem(type = LinearTableCellItemType.HEADER, colSpan = 1, rowSpan = 1, content = listOf(LinearText("4", LinearTextBlockStyle.TEXT)))) + }, + result[0], + ) + } + + @Test + fun `table with colspan, rowspan, and a double span`() { + val html = + """ + + + + + + + + + + + + + + + + +
NameAgeMoney Money
Bob${'$'}3000
${'$'}4001
+ """.trimIndent() + val baseUrl = "https://example.com" + + val result = linearizer.linearize(html, baseUrl).elements + + assertEquals(1, result.size, "Expected one item: $result") + val table = result[0] as LinearTable + assertEquals( + LinearTable.build { + newRow() + add(LinearTableCellItem(type = LinearTableCellItemType.HEADER, colSpan = 1, rowSpan = 1, content = listOf(LinearText("Name", LinearTextBlockStyle.TEXT)))) + add(LinearTableCellItem(type = LinearTableCellItemType.HEADER, colSpan = 1, rowSpan = 1, content = listOf(LinearText("Age", LinearTextBlockStyle.TEXT)))) + add(LinearTableCellItem(type = LinearTableCellItemType.HEADER, colSpan = 2, rowSpan = 1, content = listOf(LinearText("Money Money", LinearTextBlockStyle.TEXT)))) + newRow() + add(LinearTableCellItem(type = LinearTableCellItemType.DATA, colSpan = 2, rowSpan = 2, content = listOf(LinearText("Bob", LinearTextBlockStyle.TEXT)))) + add(LinearTableCellItem(type = LinearTableCellItemType.DATA, colSpan = 1, rowSpan = 1, content = listOf(LinearText("${'$'}300", LinearTextBlockStyle.TEXT)))) + add(LinearTableCellItem(type = LinearTableCellItemType.DATA, colSpan = 1, rowSpan = 1, content = listOf(LinearText("0", LinearTextBlockStyle.TEXT)))) + newRow() + add(LinearTableCellItem(type = LinearTableCellItemType.DATA, colSpan = 1, rowSpan = 1, content = listOf(LinearText("${'$'}400", LinearTextBlockStyle.TEXT)))) + add(LinearTableCellItem(type = LinearTableCellItemType.DATA, colSpan = 1, rowSpan = 1, content = listOf(LinearText("1", LinearTextBlockStyle.TEXT)))) + }, + table, + ) + + assertEquals(4, table.colCount, "Expected 4 columns: $table") + assertEquals(3, table.rowCount, "Expected 3 rows: $table") + } + + @Test + fun `table with zero colspan spans entire row`() { + val html = + """ + + + + + + + + + + +
NameAgeMoney
Bob
+ """.trimIndent() + val baseUrl = "https://example.com" + + val result = linearizer.linearize(html, baseUrl).elements + + assertEquals(1, result.size, "Expected one item: $result") + val table = result[0] as LinearTable + assertEquals( + LinearTable.build { + newRow() + add(LinearTableCellItem(type = LinearTableCellItemType.HEADER, colSpan = 1, rowSpan = 1, content = listOf(LinearText("Name", LinearTextBlockStyle.TEXT)))) + add(LinearTableCellItem(type = LinearTableCellItemType.HEADER, colSpan = 1, rowSpan = 1, content = listOf(LinearText("Age", LinearTextBlockStyle.TEXT)))) + add(LinearTableCellItem(type = LinearTableCellItemType.HEADER, colSpan = 1, rowSpan = 1, content = listOf(LinearText("Money", LinearTextBlockStyle.TEXT)))) + newRow() + add(LinearTableCellItem(type = LinearTableCellItemType.DATA, colSpan = 0, rowSpan = 1, content = listOf(LinearText("Bob", LinearTextBlockStyle.TEXT)))) + }, + table, + ) + + assertEquals(3, table.colCount, "Expected 3 columns: $table") + assertEquals(2, table.rowCount, "Expected 2 rows: $table") + } + + @Test + fun `cowboy table`() { + val html = + """ + + + + + + + + + + + + + + + +
+

Table 1. +

This table demonstrates the table rendering capabilities of Feeder's Reader view. This caption + is by the spec allowed to contain most objects, except other tables. See + flow content. +

Name + Number + Money +
First and Last name + What number human are you? + How much money have you collected? +
No Comment + Early! + Sad +
Bob + 66 + ${'$'}3 +
Alice + 999 + ${'$'}999999 +
:O + OMG Col span 2 +
WHAAAT. Triple span?! +
Firefox special zero span means to the end! +
+ """.trimIndent() + + val baseUrl = "https://example.com" + + val result = linearizer.linearize(html, baseUrl).elements + + assertEquals(1, result.size, "Expected one item: $result") + val table = result[0] as LinearTable + + // Filler items are dropped + assertEquals(table.cells.size - 3, table.toTableData().cells.size, "Expected filler items to be dropped") + } + + @Test + fun `table with thead, tbody, and tfoot`() { + val html = + """ + + + + + +
NameNumberMoney +
Bob66${'$'}3 +
Alice999${'$'}999999 +
No CommentEarly!Sad +
+ + """.trimIndent() + val baseUrl = "https://example.com" + + val result = linearizer.linearize(html, baseUrl).elements + + assertEquals(1, result.size, "Expected one item: $result") + val expected = + LinearTable.build { + newRow() + add(LinearTableCellItem(type = LinearTableCellItemType.HEADER, colSpan = 1, rowSpan = 1, content = listOf(LinearText("Name", LinearTextBlockStyle.TEXT)))) + add(LinearTableCellItem(type = LinearTableCellItemType.HEADER, colSpan = 1, rowSpan = 1, content = listOf(LinearText("Number", LinearTextBlockStyle.TEXT)))) + add(LinearTableCellItem(type = LinearTableCellItemType.HEADER, colSpan = 1, rowSpan = 1, content = listOf(LinearText("Money", LinearTextBlockStyle.TEXT)))) + newRow() + add(LinearTableCellItem(type = LinearTableCellItemType.DATA, colSpan = 1, rowSpan = 1, content = listOf(LinearText("Bob", LinearTextBlockStyle.TEXT)))) + add(LinearTableCellItem(type = LinearTableCellItemType.DATA, colSpan = 1, rowSpan = 1, content = listOf(LinearText("66", LinearTextBlockStyle.TEXT)))) + add(LinearTableCellItem(type = LinearTableCellItemType.DATA, colSpan = 1, rowSpan = 1, content = listOf(LinearText("${'$'}3", LinearTextBlockStyle.TEXT)))) + newRow() + add(LinearTableCellItem(type = LinearTableCellItemType.DATA, colSpan = 1, rowSpan = 1, content = listOf(LinearText("Alice", LinearTextBlockStyle.TEXT)))) + add(LinearTableCellItem(type = LinearTableCellItemType.DATA, colSpan = 1, rowSpan = 1, content = listOf(LinearText("999", LinearTextBlockStyle.TEXT)))) + add(LinearTableCellItem(type = LinearTableCellItemType.DATA, colSpan = 1, rowSpan = 1, content = listOf(LinearText("${'$'}999999", LinearTextBlockStyle.TEXT)))) + newRow() + add(LinearTableCellItem(type = LinearTableCellItemType.DATA, colSpan = 1, rowSpan = 1, content = listOf(LinearText("No Comment", LinearTextBlockStyle.TEXT)))) + add(LinearTableCellItem(type = LinearTableCellItemType.DATA, colSpan = 1, rowSpan = 1, content = listOf(LinearText("Early!", LinearTextBlockStyle.TEXT)))) + add(LinearTableCellItem(type = LinearTableCellItemType.DATA, colSpan = 1, rowSpan = 1, content = listOf(LinearText("Sad", LinearTextBlockStyle.TEXT)))) + } + val firstDiffIndex = + expected.cells.map { (key, linearTableCellItem) -> + val other = (result[0] as LinearTable).cells[key] + if (linearTableCellItem != other) { + key + } else { + null + } + }.filterNotNull().firstOrNull() + val firstDiff: String? = + firstDiffIndex?.let { index -> + "First differing cell at index $index: ${expected.cells[index]} vs ${(result[0] as LinearTable).cells[index]}" + } + assertEquals( + expected, + result[0], + firstDiff ?: "Expected table: $expected\nActual table: ${result[0]}", + ) + } + + @Test + fun `arctechnica list items are actually images readability4j`() { + val html = + """ +
    +
  • +
    +
    + +

    Microsoft's Surface Pro 11 comes with Arm chips and an optional OLED display panel.

    +

    Microsoft

    +
    +
  • +
  • +
    +
    + +

    The Surface Pro 11's design is near-identical to the Surface Pro 8 and Surface Pro 9, and they're compatible with the same accessories.

    +

    Microsoft

    +
    +
  • +
  • +
    +
    + +

    Two USB-C ports, no headphone jack. A Smart Connect port is on the other side.

    +

    Microsoft

    +
    +
  • +
  • +
    +
    + +

    The new Surface Laptop 7, available in 13.8- and 15-inch models.

    +

    Microsoft

    +
    +
  • +
  • +
    +
    + +

    The keyboard, complete with Copilot key.

    +

    Microsoft

    +
    +
  • +
  • +
    +
    + +

    You get one more USB-C port than you did before. USB-A, Smart Connect, and the headphone jack are also present and accounted for.

    +

    Microsoft

    +
    +
  • +
+ """.trimIndent() + + val baseUrl = "https://example.com" + + val result = linearizer.linearize(html, baseUrl).elements + + assertEquals(1, result.size, "Expected items: $result") + + // Expect one un ordered list + val linearList = result[0] as LinearList + + // This has 6 items + assertEquals(6, linearList.items.size, "Expected list items: $linearList") + + // All contain only a single image + linearList.items.forEach { + assertEquals(1, it.content.size, "Expected single image: $it") + // Image url ends with jpeg + val image = it.content[0] as LinearImage + assertTrue("Expected jpeg image: $image") { + image.sources[0].imgUri.startsWith("https://cdn.arstechnica.net/wp-content/uploads/2024/05/") + image.sources[0].imgUri.endsWith(".jpeg") + } + } + } + + @Test + fun `arstechnica list items are actually images`() { + val html = + """ + + """.trimIndent() + + val baseUrl = "https://arstechnica.com" + + val result = linearizer.linearize(html, baseUrl).elements + + assertEquals(1, result.size, "Expected items: $result") + + // Expect one un ordered list + val linearList = result[0] as LinearList + + // This has 6 items + assertEquals(6, linearList.items.size, "Expected list items: $linearList") + + // All contain only a single image + linearList.items.forEach { + assertEquals(1, it.content.size, "Expected single image: $it") + // Image url ends with jpeg + val image = it.content[0] as LinearImage + assertTrue("Expected jpeg image: $image") { + image.sources[0].imgUri.startsWith("https://cdn.arstechnica.net/wp-content/uploads/2024/05/") + image.sources[0].imgUri.endsWith(".jpeg") + } + } + } + + @Test + fun `test with feeder news changelog`() { + val html = + """ +

Aitor Salaberria (1):

+
    +
  • [d719ced2] Translated using Weblate (Basque)
  • +
+

Belmar Begić (1):

+
    +
  • [42e567d5] Updated Bosnian translation using Weblate
  • +
+

Jonas Kalderstam (7):

+
    +
  • [f2486f3c] Upgraded some dependency versions
  • +
  • [e69ed180] Fixed sync indicator: should now stay on screen as long as + sync is running
  • +
  • [10358f20] Fixed deprecation warnings
  • +
  • [05e1066c] Removed unused proguard rule
  • +
  • [8d87a2a1] Fixed broken navigation after version upgrade
  • +
  • [cd1d3df0] Fixed foreground service changes in Android 14
  • +
  • [7939495a] Fixed Saved Articles count only showing unread instead of + total
  • +
+

Vitor Henrique (1):

+
    +
  • [67ab5429] Updated Portuguese (Brazil) translation using Weblate
  • +
+

bowornsin (1):

+
    +
  • [e699f62a] Updated Thai translation using Weblate
  • +
+

ngocanhtve (1):

+
    +
  • [fa7eb98a] Translated using Weblate (Vietnamese)
  • +
+

zmni (1):

+
    +
  • [b56e987b] Updated Indonesian translation using Weblate
  • +
+ """.trimIndent() + val baseUrl = "https://news.nononsenseapps.com" + + val result = linearizer.linearize(html, baseUrl).elements + + assertEquals(14, result.size, "Expected items: $result") + } + + @Test + fun `cowboyprogrammer transmission`() { + val html = + """ + + +

Quick post to immortilize the configuration to get transmission-daemon working with a + wireguard tunnel.

+ +

If you don’t have a wireguard tunnel, head to https://mullvad.net/en/ and get one.

+ +

Transmission config

+ +

First, the transmission config is + really simple:

+ +
#/etc/transmission-daemon/settings.json
+            {
+              [...]
+            
+              "bind-address-ipv4": "X.X.X.X",
+              "bind-address-ipv6": "xxxx:xxxx:xxxx:xxxx::xxxx",
+              "peer-port": 24328,
+              "rpc-bind-address": "0.0.0.0",
+            
+              [...]
+            }
+            
+ + +

I also run the daemon using the following service for good measure:

+ + +
# /etc/systemd/system/transmission-daemon.service
+            [Unit]
+            Description=Transmission BitTorrent Daemon Under VPN
+            After=network-online.target
+            After=wg-quick@wgtorrents.service
+            Requires=wg-quick@wgtorrents.service
+            
+            [Service]
+            User=debian-transmission
+            ExecStart=/usr/bin/transmission-daemon -f --log-error --bind-address-ipv4 X.X.X.X --bind-address-ipv6 xxxx:xxxx:xxxx:xxxx::xxxx --rpc-bind-address 0.0.0.0
+            
+            [Install]
+            WantedBy=multi-user.target
+            
+            
+ + +

Wireguard config

+ +

All the magic happens in the PostUp rule where + a routing rule is added for any traffic originating from the wireguard IP addresses.

+ + +
#/etc/wireguard/wgtorrents.conf
+            [Interface]
+            PrivateKey=
+            Address=X.X.X.X/32,xxxx:xxxx:xxxx:xxxx::xxxx/128
+            # Inhibit default table creation
+            Table=off
+            # But do create a default route for the specific ip addresses
+            PostUp = systemd-resolve -i %i --set-dns=193.138.218.74 --set-domain=~.; ip rule add from X.X.X.X table 42; ip route add default dev %i table 42; ip -6 rule add from xxxx:xxxx:xxxx:xxxx::xxxx table 42
+            PostDown = ip rule del from X.X.X.X table 42; ip -6 rule del from xxxx:xxxx:xxxx:xxxx::xxxx table 42
+            
+            [Peer]
+            PersistentKeepalive=25
+            PublicKey=m4jnogFbACz7LByjo++8z5+1WV0BuR1T7E1OWA+n8h0=
+            Endpoint=se4-wireguard.mullvad.net:51820
+            AllowedIPs=0.0.0.0/0,::/0
+            
+ + +

Enable it all by doing

+ + +
systemctl enable --now wg-quick@wgtorrents.timer
+            systemctl enable --now transmission-daemon.service
+            
+ """.trimIndent() + + val baseUrl = "https://cowboyprogrammer.org" + + val result = linearizer.linearize(html, baseUrl).elements + + assertEquals(12, result.size, "Expected items: $result") + } + + @Test + fun `cowboyprogrammer exhaustive`() { + val html = + """ +

Just a placeholder so far. Needed a known blog to test a few things with.

Animated images!

Animated Webp image

Animated Gif +

And at long last animated in the reader itself!

Animated reader

Text formatting

A link to Gitlab.

+

Some inline code formatting.

And then

+
A code block with some lines of code should be scrollable if one very very long line with many sssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssss 
+

A table!

+ + + + + + + + + + + + + +

Table 1. +

This table demonstrates the table rendering capabilities of Feeder's Reader view. This + caption is by the spec allowed to contain most objects, except other tables. See flow + content.

Name + Number + Money +
First and Last name + What number human are you? + How much money have you collected? +
No Comment + Early! + Sad +
Bob + 66 + ${'$'}3 +
Alice + 999 + ${'$'}999999 +
:O + OMG Col span 2 +
WHAAAT. Triple span?! +
Firefox special zero span means to the end! +

And this is a table with an image in it

+ + + + + + + + + +
Debian logo
Should be a debian logo above

And this is a link with an image inside

A meme +

Here is a blockquote with a nested quote in it:

+

Once upon a time

+

A dev coded compose it was written:

+

@Composable fun FunctionFuns()

+

And there was code

Here comes some headers

Header + 1

Header 2

Header 3

Header + 4

Header 5
Header 6

Lists

+

Here are some lists

+
    +
  • Bullet
  • +
  • Point
  • +
  • List
  • +

and

+
    +
  1. Numbered
  2. +
  3. List
  4. +
  5. Here
  6. +

Videos

Here’s an embedded youtube video

+

Here’s an HTML5 video

+ +

Other posts in the Rewriting Feeder in Compose series:

+
    +
  • 2021-06-09 — The biggest update to Feeder so far
  • +
+ """.trimIndent() + + val baseUrl = "https://cowboyprogrammer.org" + + val result = linearizer.linearize(html, baseUrl).elements + + assertEquals(1, result.count { it is LinearTable }, "Expected one table in result") + assertEquals(8, result.filterIsInstance().first().rowCount, "Expected table with 8 rows") + } + + @Test + fun `table with single column is optimized out`() { + val html = + """ + + + + + + + + + +
Single column table
Second row
+ """.trimIndent() + + val baseUrl = "https://example.com" + + val result = linearizer.linearize(html, baseUrl).elements + + assertEquals(2, result.size, "Expected two text items: $result") + assertTrue("Expected all to be linear text items: $result") { + result.all { it is LinearText } + } + } + + @Test + fun `insane nested table`() { + // from kill-the-newsletter + val html = + """ + + + + + + +
+
+ + + + + + +
+ + + + + + +
+ + + + + + +
+
+

+   +

+

+ Dear E S Znqiiwuyp Sjpv, +

+

+ You' + ve subscribed to the OpenSciences.org newsletter. Please confirm this was + your intention:

+

+   +

+

+   + Click + here to confirm your subscription‍ +
+

+

+   +
+

+
+
+
+
+
+
+ """.trimIndent() + + // Tables with a single row and column are optimized out + + val baseUrl = "https://kill-the-newsletter.com" + + val result = linearizer.linearize(html, baseUrl).elements + + assertEquals(4, result.size, "Expected text elements: $result") + + assertTrue("Expected all to be linear text items: $result") { + result.all { it is LinearText } + } + } +} diff --git a/app/src/test/java/com/nononsenseapps/feeder/ui/compose/text/HtmlToComposableUnitTest.kt b/app/src/test/java/com/nononsenseapps/feeder/ui/compose/text/HtmlToComposableUnitTest.kt deleted file mode 100644 index 235dd3a12..000000000 --- a/app/src/test/java/com/nononsenseapps/feeder/ui/compose/text/HtmlToComposableUnitTest.kt +++ /dev/null @@ -1,324 +0,0 @@ -@file:Suppress("ktlint:standard:max-line-length") - -package com.nononsenseapps.feeder.ui.compose.text - -import io.mockk.every -import io.mockk.mockk -import org.jsoup.nodes.Element -import org.junit.Before -import org.junit.Test -import kotlin.test.assertEquals -import kotlin.test.assertFalse -import kotlin.test.assertTrue - -class HtmlToComposableUnitTest { - private val element = mockk() - - @Before - fun setup() { - every { element.attr("width") } returns "" - every { element.attr("height") } returns "" - every { element.attr("data-img-url") } returns "" - } - - @Test - fun findImageSrcWithNoSrc() { - every { element.attr("srcset") } returns "" - every { element.attr("abs:src") } returns "" - - val result = getImageSource("http://foo", element) - - assertFalse(result.hasImage) - } - - @Test - fun findImageOnlySrcWithZeroPixels() { - every { element.attr("srcset") } returns "" - every { element.attr("abs:src") } returns "http://foo/image.jpg" - every { element.attr("width") } returns "0" - every { element.attr("height") } returns "0" - - val result = getImageSource("http://foo", element) - - assertTrue(result.notHasImage) - } - - @Test - fun findImageBestZeroPixelSrcSetIsNoImage() { - every { element.attr("srcset") } returns "header640.png 0w" - every { element.attr("abs:src") } returns "" - every { element.attr("width") } returns "" - every { element.attr("height") } returns "" - - val result = getImageSource("http://foo", element) - - assertTrue(result.hasImage) - - val maxSize = 1 - val best = result.getBestImageForMaxSize(maxSize, 1.0f) - assertTrue("$best should be NoImageCandidate") { - best is NoImageCandidate - } - } - - @Test - fun findImageOnlySrc() { - every { element.attr("srcset") } returns "" - every { element.attr("abs:src") } returns "http://foo/image.jpg" - - val result = getImageSource("http://foo", element) - - assertTrue(result.hasImage) - val best = result.getBestImageForMaxSize(1, 1.0f) - assertEquals("http://foo/image.jpg", best.url) - } - - @Test - fun findImageOnlySingleSrcSet() { - every { element.attr("srcset") } returns "image.jpg" - every { element.attr("abs:src") } returns "" - - val result = getImageSource("http://foo", element) - - assertTrue(result.hasImage) - val best = result.getBestImageForMaxSize(1, 1.0f) - assertEquals("http://foo/image.jpg", best.url) - } - - @Test - fun findImageBestMinSrcSet() { - every { element.attr("srcset") } returns "header640.png 640w, header960.png 960w, header2x.png 2x, header3.0x.png 3.0x, header.png" - every { element.attr("abs:src") } returns "" - - val result = getImageSource("http://foo", element) - - assertTrue(result.hasImage) - - val maxSize = 1 - val best = result.getBestImageForMaxSize(maxSize, 1.0f) - assertEquals("http://foo/header.png", best.url) - } - - @Test - fun findImageBest640SrcSet() { - every { element.attr("srcset") } returns "header640.png 640w, header960.png 960w, header2x.png 2x, header3.0x.png 3.0x, header.png" - every { element.attr("abs:src") } returns "" - - val result = getImageSource("http://foo", element) - - assertTrue(result.hasImage) - - val maxSize = 640 - val best = result.getBestImageForMaxSize(maxSize, 1.0f) - assertEquals("http://foo/header640.png", best.url) - } - - @Test - fun findImageBest960SrcSet() { - every { element.attr("srcset") } returns "header640.png 640w, header960.png 960w, header2x.png 2x, header3.0x.png 3.0x, header.png" - every { element.attr("abs:src") } returns "" - - val result = getImageSource("http://foo", element) - - assertTrue(result.hasImage) - - val maxSize = 900 - val best = result.getBestImageForMaxSize(maxSize, 8.0f) - assertEquals("http://foo/header960.png", best.url) - } - - @Test - fun findImageBest650SrcSet() { - every { element.attr("srcset") } returns "header640.png 640w, header960.png 960w, header2x.png 2x, header3.0x.png 3.0x, header.png" - every { element.attr("abs:src") } returns "" - - val result = getImageSource("http://foo", element) - - assertTrue(result.hasImage) - - val maxSize = 650 - val best = result.getBestImageForMaxSize(maxSize, 7.0f) - assertEquals("http://foo/header640.png", best.url) - } - - @Test - fun findImageBest950SrcSet() { - every { element.attr("srcset") } returns "header640.png 640w, header960.png 960w, header2x.png 2x, header3.0x.png 3.0x, header.png" - every { element.attr("abs:src") } returns "" - - val result = getImageSource("http://foo", element) - - assertTrue(result.hasImage) - - val maxSize = 950 - val best = result.getBestImageForMaxSize(maxSize, 7.0f) - assertEquals("http://foo/header960.png", best.url) - } - - @Test - fun findImageBest1500SrcSet() { - every { element.attr("srcset") } returns "header640.png 640w, header960.png 960w, header2x.png 2x, header3.0x.png 3.0x, header.png" - every { element.attr("abs:src") } returns "" - - val result = getImageSource("http://foo", element) - - assertTrue(result.hasImage) - - val maxSize = 1500 - val best = result.getBestImageForMaxSize(maxSize, 8.0f) - assertEquals("http://foo/header960.png", best.url) - } - - @Test - fun findImageBest3xSrcSet() { - every { element.attr("srcset") } returns "header2x.png 2x, header3.0x.png 3.0x, header.png" - every { element.attr("abs:src") } returns "" - - val result = getImageSource("http://foo", element) - - assertTrue(result.hasImage) - - val maxSize = 1 - val best = result.getBestImageForMaxSize(maxSize, 3.0f) - assertEquals("http://foo/header3.0x.png", best.url) - } - - @Test - fun findImageBest1xSrcSet() { - every { element.attr("srcset") } returns "header2x.png 2x, header3.0x.png 3.0x, header.png" - every { element.attr("abs:src") } returns "" - - val result = getImageSource("http://foo", element) - - assertTrue(result.hasImage) - - val maxSize = 1 - val best = result.getBestImageForMaxSize(maxSize, 1.0f) - assertEquals("http://foo/header.png", best.url) - } - - @Test - fun findImageBestJunkSrcSet() { - every { element.attr("srcset") } returns "header2x.png 2Y" - every { element.attr("abs:src") } returns "http://foo/header.png" - - val result = getImageSource("http://foo", element) - - assertTrue(result.hasImage) - - val maxSize = 1 - val best = result.getBestImageForMaxSize(maxSize, 1.0f) - assertEquals("http://foo/header.png", best.url) - } - - @Test - fun findImageBestPoliticoSrcSet() { - every { - element.attr("srcset") - } returns "https://www.politico.eu/cdn-cgi/image/width=1024,quality=80,onerror=redirect,format=auto/wp-content/uploads/2022/10/07/thumbnail_Kal-econ-cartoon-10-7-22synd.jpeg 1024w, https://www.politico.eu/cdn-cgi/image/width=300,quality=80,onerror=redirect,format=auto/wp-content/uploads/2022/10/07/thumbnail_Kal-econ-cartoon-10-7-22synd.jpeg 300w, https://www.politico.eu/cdn-cgi/image/width=1280,quality=80,onerror=redirect,format=auto/wp-content/uploads/2022/10/07/thumbnail_Kal-econ-cartoon-10-7-22synd.jpeg 1280w" - every { - element.attr("abs:src") - } returns "https://www.politico.eu/wp-content/uploads/2022/10/07/thumbnail_Kal-econ-cartoon-10-7-22synd-1024x683.jpeg" - every { element.attr("width") } returns "1024" - every { element.attr("height") } returns "683" - - val result = getImageSource("https://www.politico.eu/feed/", element) - - assertTrue(result.hasImage) - - val maxSize = 1024 - val best = - result.getBestImageForMaxSize( - maxSize, - 8.0f, - ) - assertEquals( - "https://www.politico.eu/cdn-cgi/image/width=1024,quality=80,onerror=redirect,format=auto/wp-content/uploads/2022/10/07/thumbnail_Kal-econ-cartoon-10-7-22synd.jpeg", - best.url, - ) - } - - @Test - fun findImageForTheVerge() { - /* - A pen pointing to a piece of LK-99 standing on its side above a magnet. - */ - - every { - element.attr("srcset") - } returns "https://duet-cdn.vox-cdn.com/thumbor/184x0:2614x1535/16x11/filters:focal(1847x240:1848x241):format(webp)/cdn.vox-cdn.com/uploads/chorus_asset/file/24842461/Screenshot_2023_08_10_at_12.22.58_PM.png 16w, https://duet-cdn.vox-cdn.com/thumbor/184x0:2614x1535/32x21/filters:focal(1847x240:1848x241):format(webp)/cdn.vox-cdn.com/uploads/chorus_asset/file/24842461/Screenshot_2023_08_10_at_12.22.58_PM.png 32w, https://duet-cdn.vox-cdn.com/thumbor/184x0:2614x1535/48x32/filters:focal(1847x240:1848x241):format(webp)/cdn.vox-cdn.com/uploads/chorus_asset/file/24842461/Screenshot_2023_08_10_at_12.22.58_PM.png 48w, https://duet-cdn.vox-cdn.com/thumbor/184x0:2614x1535/64x43/filters:focal(1847x240:1848x241):format(webp)/cdn.vox-cdn.com/uploads/chorus_asset/file/24842461/Screenshot_2023_08_10_at_12.22.58_PM.png 64w, https://duet-cdn.vox-cdn.com/thumbor/184x0:2614x1535/96x64/filters:focal(1847x240:1848x241):format(webp)/cdn.vox-cdn.com/uploads/chorus_asset/file/24842461/Screenshot_2023_08_10_at_12.22.58_PM.png 96w, https://duet-cdn.vox-cdn.com/thumbor/184x0:2614x1535/128x85/filters:focal(1847x240:1848x241):format(webp)/cdn.vox-cdn.com/uploads/chorus_asset/file/24842461/Screenshot_2023_08_10_at_12.22.58_PM.png 128w, https://duet-cdn.vox-cdn.com/thumbor/184x0:2614x1535/256x171/filters:focal(1847x240:1848x241):format(webp)/cdn.vox-cdn.com/uploads/chorus_asset/file/24842461/Screenshot_2023_08_10_at_12.22.58_PM.png 256w, https://duet-cdn.vox-cdn.com/thumbor/184x0:2614x1535/376x251/filters:focal(1847x240:1848x241):format(webp)/cdn.vox-cdn.com/uploads/chorus_asset/file/24842461/Screenshot_2023_08_10_at_12.22.58_PM.png 376w, https://duet-cdn.vox-cdn.com/thumbor/184x0:2614x1535/384x256/filters:focal(1847x240:1848x241):format(webp)/cdn.vox-cdn.com/uploads/chorus_asset/file/24842461/Screenshot_2023_08_10_at_12.22.58_PM.png 384w, https://duet-cdn.vox-cdn.com/thumbor/184x0:2614x1535/415x277/filters:focal(1847x240:1848x241):format(webp)/cdn.vox-cdn.com/uploads/chorus_asset/file/24842461/Screenshot_2023_08_10_at_12.22.58_PM.png 415w, https://duet-cdn.vox-cdn.com/thumbor/184x0:2614x1535/480x320/filters:focal(1847x240:1848x241):format(webp)/cdn.vox-cdn.com/uploads/chorus_asset/file/24842461/Screenshot_2023_08_10_at_12.22.58_PM.png 480w, https://duet-cdn.vox-cdn.com/thumbor/184x0:2614x1535/540x360/filters:focal(1847x240:1848x241):format(webp)/cdn.vox-cdn.com/uploads/chorus_asset/file/24842461/Screenshot_2023_08_10_at_12.22.58_PM.png 540w, https://duet-cdn.vox-cdn.com/thumbor/184x0:2614x1535/640x427/filters:focal(1847x240:1848x241):format(webp)/cdn.vox-cdn.com/uploads/chorus_asset/file/24842461/Screenshot_2023_08_10_at_12.22.58_PM.png 640w, https://duet-cdn.vox-cdn.com/thumbor/184x0:2614x1535/750x500/filters:focal(1847x240:1848x241):format(webp)/cdn.vox-cdn.com/uploads/chorus_asset/file/24842461/Screenshot_2023_08_10_at_12.22.58_PM.png 750w, https://duet-cdn.vox-cdn.com/thumbor/184x0:2614x1535/828x552/filters:focal(1847x240:1848x241):format(webp)/cdn.vox-cdn.com/uploads/chorus_asset/file/24842461/Screenshot_2023_08_10_at_12.22.58_PM.png 828w, https://duet-cdn.vox-cdn.com/thumbor/184x0:2614x1535/1080x720/filters:focal(1847x240:1848x241):format(webp)/cdn.vox-cdn.com/uploads/chorus_asset/file/24842461/Screenshot_2023_08_10_at_12.22.58_PM.png 1080w, https://duet-cdn.vox-cdn.com/thumbor/184x0:2614x1535/1200x800/filters:focal(1847x240:1848x241):format(webp)/cdn.vox-cdn.com/uploads/chorus_asset/file/24842461/Screenshot_2023_08_10_at_12.22.58_PM.png 1200w, https://duet-cdn.vox-cdn.com/thumbor/184x0:2614x1535/1440x960/filters:focal(1847x240:1848x241):format(webp)/cdn.vox-cdn.com/uploads/chorus_asset/file/24842461/Screenshot_2023_08_10_at_12.22.58_PM.png 1440w, https://duet-cdn.vox-cdn.com/thumbor/184x0:2614x1535/1920x1280/filters:focal(1847x240:1848x241):format(webp)/cdn.vox-cdn.com/uploads/chorus_asset/file/24842461/Screenshot_2023_08_10_at_12.22.58_PM.png 1920w, https://duet-cdn.vox-cdn.com/thumbor/184x0:2614x1535/2048x1365/filters:focal(1847x240:1848x241):format(webp)/cdn.vox-cdn.com/uploads/chorus_asset/file/24842461/Screenshot_2023_08_10_at_12.22.58_PM.png 2048w, https://duet-cdn.vox-cdn.com/thumbor/184x0:2614x1535/2400x1600/filters:focal(1847x240:1848x241):format(webp)/cdn.vox-cdn.com/uploads/chorus_asset/file/24842461/Screenshot_2023_08_10_at_12.22.58_PM.png 2400w" - every { - element.attr("abs:src") - } returns "https://duet-cdn.vox-cdn.com/thumbor/184x0:2614x1535/2400x1600/filters:focal(1847x240:1848x241):format(webp)/cdn.vox-cdn.com/uploads/chorus_asset/file/24842461/Screenshot_2023_08_10_at_12.22.58_PM.png" - every { element.attr("width") } returns "" - every { element.attr("height") } returns "" - - val result = getImageSource("https://www.politico.eu/feed/", element) - - assertTrue(result.hasImage) - - val maxSize = 1024 - val best = - result.getBestImageForMaxSize( - maxSize, - 8.0f, - ) - assertEquals( - "https://duet-cdn.vox-cdn.com/thumbor/184x0:2614x1535/1080x720/filters:focal(1847x240:1848x241):format(webp)/cdn.vox-cdn.com/uploads/chorus_asset/file/24842461/Screenshot_2023_08_10_at_12.22.58_PM.png", - best.url, - ) - } - - @Test - fun findImageForXDAWithDataImgUrl() { - every { - element.attr("data-img-url") - } returns "https://static1.xdaimages.com/wordpress/wp-content/uploads/2023/12/onedrive-app-for-microsoft-teams.png" - every { - element.attr("srcset") - } returns "" - every { - element.attr("abs:src") - } returns "" - every { element.attr("width") } returns "" - every { element.attr("height") } returns "" - - val result = getImageSource("https://www.xda-developers.com", element) - - assertTrue(result.hasImage) - - val maxSize = 1024 - val best = - result.getBestImageForMaxSize( - maxSize, - 8.0f, - ) - assertEquals( - "https://static1.xdaimages.com/wordpress/wp-content/uploads/2023/12/onedrive-app-for-microsoft-teams.png", - best.url, - ) - } - - @Test - fun noSourcesMeansEmptyResult() { - every { element.attr("srcset") } returns "" - every { element.attr("abs:src") } returns "" - every { element.attr("width") } returns "" - every { element.attr("height") } returns "" - - val result = getImageSource("https://www.politico.eu/feed/", element) - - assertFalse(result.hasImage) - - val maxSize = 1024 - val best = - result.getBestImageForMaxSize( - maxSize, - 8.0f, - ) - assertEquals( - "", - best.url, - ) - } -}