From e1f3c77391e83cf0ba988e70e697c412404f1309 Mon Sep 17 00:00:00 2001 From: Jonas Kalderstam Date: Wed, 15 May 2024 10:22:42 +0200 Subject: [PATCH] HTML Linearizer but no images yet Implemented figure and image Actually renders linear content WIP Liking this Great previews Layouts look good but html parsing is doing something wrong Not sure why some text is small but pretty good. Images could be larger in tables Fixed some bugs Fixed row colors for table Fixed so empty lists aren't added Added support for ArsTechnica stupid image styles No longer assumes full screen for dimensions Fixed empty space at end of blockquotes Fixed nullability assumptinos Added spans to table Added better handling of ending whitespace Added support for for bold/italic Fixed test Build apk for branch Fixed incorrect parsing of nested tables Optimized out single row tables Optimized single col tables --- .github/workflows/branch_apk.yml | 2 +- .../feeder/model/html/HtmlLinearizer.kt | 889 +++++++++++ .../feeder/model/html/LinearStuff.kt | 293 ++++ .../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 | 1300 +++++++++++++++ .../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 +- .../feeder/model/html/HtmlLinearizerTest.kt | 1296 +++++++++++++++ .../compose/text/HtmlToComposableUnitTest.kt | 324 ---- 21 files changed, 4394 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..ca6596164 --- /dev/null +++ b/app/src/main/java/com/nononsenseapps/feeder/model/html/HtmlLinearizer.kt @@ -0,0 +1,889 @@ +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.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( + candidates = 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( + candidates = 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, + ) + } + }, + ) + } + } + }, + ) + } + } + + "iframe" -> { + // TODO + } + + "rt", "rp" -> { + // Ruby text elements. Not supported. + } + + "video" -> { + // not implemented yet. + } + + 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( + LinearImageCandidate( + 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( + LinearImageCandidate( + 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( + LinearImageCandidate( + 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( + LinearImageCandidate( + imgUri = url, + pixelDensity = null, + screenWidth = null, + heightPx = height, + widthPx = width, + ), + ) + } else { + result.add( + LinearImageCandidate( + 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( + LinearImageCandidate( + imgUri = url, + pixelDensity = null, + screenWidth = null, + heightPx = height, + widthPx = width, + ), + ) + } else { + result.add( + LinearImageCandidate( + imgUri = url, + pixelDensity = null, + screenWidth = null, + heightPx = null, + widthPx = null, + ), + ) + } + } + + backgroundImage.takeIf { it.isNotBlank() }?.let { + val url = StringUtil.resolve(baseUrl, it) + result.add( + LinearImageCandidate( + 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..5f2c7613b --- /dev/null +++ b/app/src/main/java/com/nononsenseapps/feeder/model/html/LinearStuff.kt @@ -0,0 +1,293 @@ +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 candidates: List, + val caption: LinearText?, + val link: String?, +) : LinearElement + +data class LinearImageCandidate( + val imgUri: String, + val widthPx: Int?, + val heightPx: Int?, + val pixelDensity: Float?, + val screenWidth: Int?, +) 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..eafe56431 --- /dev/null +++ b/app/src/main/java/com/nononsenseapps/feeder/ui/compose/html/LinearArticleContent.kt @@ -0,0 +1,1300 @@ +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.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.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.model.html.Coordinate +import com.nononsenseapps.feeder.model.html.LinearArticle +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.LinearImageCandidate +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.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, + ) + } +} + +@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.candidates.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, +): LinearImageCandidate? = + candidates.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" + } + +@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( + candidates = + listOf( + LinearImageCandidate( + 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( + candidates = + listOf( + LinearImageCandidate( + 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/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..0ae6acc6f --- /dev/null +++ b/app/src/test/java/com/nononsenseapps/feeder/model/html/HtmlLinearizerTest.kt @@ -0,0 +1,1296 @@ +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( + candidates = + listOf( + LinearImageCandidate(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( + candidates = + listOf( + LinearImageCandidate(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( + candidates = + listOf( + LinearImageCandidate(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( + candidates = + listOf( + LinearImageCandidate(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( + candidates = + listOf( + LinearImageCandidate(imgUri = "https://example.com/image.jpg", widthPx = null, heightPx = null, screenWidth = null, pixelDensity = 1f), + LinearImageCandidate(imgUri = "https://example.com/image-2x.jpg", widthPx = null, heightPx = null, screenWidth = null, pixelDensity = 2f), + LinearImageCandidate(imgUri = "https://example.com/image-700w.jpg", widthPx = null, heightPx = null, screenWidth = 700, pixelDensity = null), + LinearImageCandidate(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( + candidates = + listOf( + LinearImageCandidate(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( + candidates = + listOf( + LinearImageCandidate(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( + candidates = + listOf( + LinearImageCandidate(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( + candidates = + listOf( + LinearImageCandidate(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( + candidates = + listOf( + LinearImageCandidate(imgUri = "https://example.com/image.jpg", widthPx = null, heightPx = null, pixelDensity = 1f, screenWidth = null), + LinearImageCandidate(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( + candidates = + listOf( + LinearImageCandidate(imgUri = "https://example.com/image.jpg", widthPx = null, heightPx = null, pixelDensity = 1f, screenWidth = null), + LinearImageCandidate(imgUri = "https://example.com/image-2x.jpg", widthPx = null, heightPx = null, pixelDensity = 2f, screenWidth = null), + LinearImageCandidate(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 `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.candidates[0].imgUri.startsWith("https://cdn.arstechnica.net/wp-content/uploads/2024/05/") + image.candidates[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.candidates[0].imgUri.startsWith("https://cdn.arstechnica.net/wp-content/uploads/2024/05/") + image.candidates[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, - ) - } -}