diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/previews/HtmlParser.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/previews/HtmlParser.kt index e76b66440..3cc0b10e4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/previews/HtmlParser.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/previews/HtmlParser.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.service.previews +import com.vitorpamplona.amethyst.commons.preview.HtmlCharsetParser import com.vitorpamplona.amethyst.commons.preview.MetaTag import com.vitorpamplona.amethyst.commons.preview.MetaTagsParser import kotlinx.coroutines.Dispatchers @@ -31,10 +32,6 @@ import java.nio.charset.Charset class HtmlParser { companion object { - val ATTRIBUTE_VALUE_CHARSET = "charset" - val ATTRIBUTE_VALUE_HTTP_EQUIV = "http-equiv" - val CONTENT = "content" - // taken from okhttp private val UNICODE_BOMS = Options.of( @@ -49,25 +46,30 @@ class HtmlParser { // UTF-32LE "ffff0000".decodeHex(), ) - - private val RE_CONTENT_TYPE_CHARSET = Regex("""charset=([^;]+)""") } suspend fun parseHtml( source: BufferedSource, type: Charset?, + ): Sequence = + parseHtml( + source.readByteArray(), + type ?: source.readBomAsCharset(), + ) + + suspend fun parseHtml( + bodyBytes: ByteArray, + type: Charset?, ): Sequence = withContext(Dispatchers.IO) { // sniff charset from Content-Type header or BOM - val sniffedCharset = type ?: source.readBomAsCharset() - if (sniffedCharset != null) { - val content = source.readByteArray().toString(sniffedCharset) + if (type != null) { + val content = bodyBytes.toString(type) return@withContext MetaTagsParser.parse(content) } // if sniffing was failed, detect charset from content - val bodyBytes = source.readByteArray() - val charset = detectCharset(bodyBytes) + val charset = HtmlCharsetParser.detectCharset(bodyBytes) val content = bodyBytes.toString(charset) return@withContext MetaTagsParser.parse(content) } @@ -82,29 +84,4 @@ class HtmlParser { -1 -> null else -> throw AssertionError() } - - private fun detectCharset(bodyBytes: ByteArray): Charset { - // try to detect charset from meta tags parsed from first 1024 bytes of body - val firstPart = String(bodyBytes, 0, 1024, Charset.forName("utf-8")) - val metaTags = MetaTagsParser.parse(firstPart) - metaTags.forEach { meta -> - val charsetAttr = meta.attr(ATTRIBUTE_VALUE_CHARSET) - if (charsetAttr.isNotEmpty()) { - runCatching { Charset.forName(charsetAttr) }.getOrNull()?.let { - return it - } - } - if (meta.attr(ATTRIBUTE_VALUE_HTTP_EQUIV).lowercase() == "content-type") { - RE_CONTENT_TYPE_CHARSET - .find(meta.attr(CONTENT)) - ?.let { - runCatching { Charset.forName(it.groupValues[1]) }.getOrNull() - }?.let { - return it - } - } - } - // defaults to UTF-8 - return Charset.forName("utf-8") - } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/previews/UrlPreview.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/previews/UrlPreview.kt index c7ea3eed4..7eb4e9f75 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/previews/UrlPreview.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/previews/UrlPreview.kt @@ -20,6 +20,8 @@ */ package com.vitorpamplona.amethyst.service.previews +import com.vitorpamplona.amethyst.commons.preview.OpenGraphParser +import com.vitorpamplona.amethyst.commons.preview.UrlInfoItem import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/UrlPreviewCard.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/UrlPreviewCard.kt index a006bd5ab..49a144591 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/UrlPreviewCard.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/UrlPreviewCard.kt @@ -40,7 +40,7 @@ import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.style.TextOverflow import coil3.compose.AsyncImage import com.vitorpamplona.amethyst.R -import com.vitorpamplona.amethyst.service.previews.UrlInfoItem +import com.vitorpamplona.amethyst.commons.preview.UrlInfoItem import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer import com.vitorpamplona.amethyst.ui.theme.MaxWidthWithHorzPadding diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/UrlPreviewState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/UrlPreviewState.kt index f8784de78..56608fb9f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/UrlPreviewState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/UrlPreviewState.kt @@ -21,7 +21,7 @@ package com.vitorpamplona.amethyst.ui.components import androidx.compose.runtime.Immutable -import com.vitorpamplona.amethyst.service.previews.UrlInfoItem +import com.vitorpamplona.amethyst.commons.preview.UrlInfoItem @Immutable sealed class UrlPreviewState { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/markdown/RenderContentAsMarkdown.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/markdown/RenderContentAsMarkdown.kt index 223431553..9f20f4687 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/markdown/RenderContentAsMarkdown.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/markdown/RenderContentAsMarkdown.kt @@ -39,9 +39,9 @@ import com.halilibo.richtext.commonmark.CommonMarkdownParseOptions import com.halilibo.richtext.commonmark.CommonmarkAstNodeParser import com.halilibo.richtext.markdown.BasicMarkdown import com.halilibo.richtext.ui.material3.RichText +import com.vitorpamplona.amethyst.commons.preview.UrlInfoItem import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.UrlCachedPreviewer -import com.vitorpamplona.amethyst.service.previews.UrlInfoItem import com.vitorpamplona.amethyst.ui.components.UrlPreviewState import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav import com.vitorpamplona.amethyst.ui.navigation.navs.INav diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/preview/HtmlCharsetParser.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/preview/HtmlCharsetParser.kt new file mode 100644 index 000000000..84f16502c --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/preview/HtmlCharsetParser.kt @@ -0,0 +1,56 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.preview + +import java.nio.charset.Charset + +object HtmlCharsetParser { + val ATTRIBUTE_VALUE_CHARSET = "charset" + val ATTRIBUTE_VALUE_HTTP_EQUIV = "http-equiv" + val CONTENT = "content" + + private val RE_CONTENT_TYPE_CHARSET = Regex("""charset=([^;]+)""") + + fun detectCharset(bodyBytes: ByteArray): Charset { + // try to detect charset from meta tags parsed from first 1024 bytes of body + val firstPart = String(bodyBytes, 0, 1024, Charset.forName("utf-8")) + val metaTags = MetaTagsParser.parse(firstPart) + metaTags.forEach { meta -> + val charsetAttr = meta.attr(ATTRIBUTE_VALUE_CHARSET) + if (charsetAttr.isNotEmpty()) { + runCatching { Charset.forName(charsetAttr) }.getOrNull()?.let { + return it + } + } + if (meta.attr(ATTRIBUTE_VALUE_HTTP_EQUIV).lowercase() == "content-type") { + RE_CONTENT_TYPE_CHARSET + .find(meta.attr(CONTENT)) + ?.let { + runCatching { Charset.forName(it.groupValues[1]) }.getOrNull() + }?.let { + return it + } + } + } + // defaults to UTF-8 + return Charset.forName("utf-8") + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/previews/OpenGraphParser.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/preview/OpenGraphParser.kt similarity index 97% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/service/previews/OpenGraphParser.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/preview/OpenGraphParser.kt index cec53b311..6543b3d08 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/previews/OpenGraphParser.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/preview/OpenGraphParser.kt @@ -18,9 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.service.previews - -import com.vitorpamplona.amethyst.commons.preview.MetaTag +package com.vitorpamplona.amethyst.commons.preview class OpenGraphParser { class Result( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/previews/UrlInfoItem.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/preview/UrlInfoItem.kt similarity index 93% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/service/previews/UrlInfoItem.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/preview/UrlInfoItem.kt index e2f483482..7b28bda05 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/previews/UrlInfoItem.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/preview/UrlInfoItem.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.service.previews +package com.vitorpamplona.amethyst.commons.preview import androidx.compose.runtime.Immutable import java.net.URL @@ -31,7 +31,7 @@ class UrlInfoItem( val image: String = "", val mimeType: String, ) { - val verifiedUrl = kotlin.runCatching { URL(url) }.getOrNull() + val verifiedUrl = runCatching { URL(url) }.getOrNull() val imageUrlFullPath = if (image.startsWith("/")) { URL(verifiedUrl, image).toString()