package org.terst.feedreader.ui import java.net.URI // How a tap on an article image is noticed at all, and turned into a URL the viewer // (ImageZoom.kt) can open. Split from the viewer itself because this half runs at // HTML-assembly time, inside wrapReaderHtml -- it's reader-markup plumbing, not gestures. // // Article images live inside the reader's WebView, which runs with JavaScript off (see // ReaderWebView) -- so there's no in-page script that could notice a tap on an and // report it out. And WebView's own built-in zoom (setSupportZoom/builtInZoomControls) isn't // an option either: pinching in the reader is already taken, deliberately, by the // reader-font-size gesture, and page zoom would scale the whole article's layout rather than // giving an image the full screen. // // So tap detection is done the one way that works with JS off: every image the reader renders // is wrapped in a link to a scheme nothing can actually navigate to, which WebView then hands // to shouldOverrideUrlLoading -- the same interception point that already routes ordinary // links and YouTube thumbnails. The callback opens ImageZoomDialog instead of following it. private const val IMAGE_ZOOM_SCHEME = "feedreader-image:" // Matches an opening , a closing , or an -- in document order, so a single pass // can tell which images are already inside a link. `, //
etc., since \b requires a non-word character right after the "a". private val anchorOrImageTagRegex = Regex("""]*>||]*>""", RegexOption.IGNORE_CASE) private val imgSrcRegex = Regex("""\bsrc\s*=\s*(?:"([^"]*)"|'([^']*)')""", RegexOption.IGNORE_CASE) private val imageFileExtensions = listOf(".jpg", ".jpeg", ".png", ".gif", ".webp", ".avif", ".bmp", ".heic", ".heif") /** * Wraps every article image that isn't already inside a link in a zoom link, so tapping it * reaches [zoomableImageTarget] via the reader WebView's shouldOverrideUrlLoading. * * Images that are already inside a link are left exactly as they were -- their link is doing * something already (ReaderExtractor's synthesized YouTube thumbnail is precisely this shape, * and wrapping it would swallow the tap that plays the video). Those are still zoomable when * the link points at an image file, which [zoomableImageTarget] handles on the other side. */ fun linkifyImagesForZoom(html: String, baseUrl: String? = null): String = buildString { var copiedUpTo = 0 var anchorDepth = 0 for (tag in anchorOrImageTagRegex.findAll(html)) { val text = tag.value when { text.startsWith(" if (anchorDepth > 0) anchorDepth-- text.startsWith(" anchorDepth++ anchorDepth == 0 -> { val src = imgSrcRegex.find(text)?.let { it.groupValues[1].ifEmpty { it.groupValues[2] } } val href = src?.let { zoomHrefForSrc(it, baseUrl) } if (href != null) { append(html, copiedUpTo, tag.range.first) append("").append(text).append("") copiedUpTo = tag.range.last + 1 } } } } append(html, copiedUpTo, html.length) } private fun zoomHrefForSrc(src: String, baseUrl: String?): String? { val trimmed = src.trim() val absolute = when { // Protocol-relative (//host/path) is common in feed markup and resolves fine in the // page itself, but our scheme-prefixed href has to carry a complete URL on its own. trimmed.startsWith("//") -> "https:$trimmed" trimmed.startsWith("http://", ignoreCase = true) || trimmed.startsWith("https://", ignoreCase = true) -> trimmed else -> resolvedAgainstBase(trimmed, baseUrl) } ?: return null // The src came out of a quoted attribute and goes straight back into one, already escaped // as the source wrote it. A raw quote or angle bracket could only arrive via a // single-quoted attribute, and would break out of the href -- drop those instead. return if (absolute.any { it == '"' || it == '<' || it == '>' }) null else IMAGE_ZOOM_SCHEME + absolute } /** * What a relative src actually points at, or null if there's no way to tell or it isn't * something the viewer could open. * * A feed's own HTML routinely keeps its images site-relative (Ftrain's is * entirely ``), and that path is what the non-reader rendering * shows verbatim -- reader mode never hits this because Readability rewrites every src to an * absolute URL before we see it. The page resolves those against the base URL that * loadReaderHtml hands loadDataWithBaseURL, which is the article's own link, so resolving them * the same way here yields exactly the URL the WebView itself fetched. With no base URL there * is still nothing to resolve against, and the image stays un-zoomable rather than getting a * dead link. */ private fun resolvedAgainstBase(src: String, baseUrl: String?): String? { if (src.isEmpty() || baseUrl.isNullOrBlank()) return null // URI() rejects a src with a space or other illegal character in it rather than guessing, // which is the right answer here too -- an un-zoomable image beats a dead link. val resolved = runCatching { URI(baseUrl).resolve(src) }.getOrNull() // data: and javascript: srcs are already absolute, so resolve() hands them straight back; // only a real network URL is something Coil could ever open. return when (resolved?.scheme?.lowercase()) { "http", "https" -> resolved.toString() else -> null } } /** * The image a tapped URL should open full-screen, or null if it isn't an image at all. * * Covers both halves of the problem: links [linkifyImagesForZoom] built, and links the article * itself already had that point straight at an image file (WordPress's default "link to media * file" wraps article images in exactly that, which used to send a tap off to LinkReaderScreen * to try and run Readability over a JPEG). */ fun zoomableImageTarget(url: String): String? = when { url.startsWith(IMAGE_ZOOM_SCHEME, ignoreCase = true) -> url.substring(IMAGE_ZOOM_SCHEME.length).takeIf { it.isNotBlank() } looksLikeImageUrl(url) -> url else -> null } // Extension-sniffing, not a content-type check -- there's no response to inspect at the point // a link is tapped. SVG is deliberately not in the list: Coil's default decoders can't render // one, so a viewer opened on it would just show an empty black screen. private fun looksLikeImageUrl(url: String): Boolean { val path = url.substringBefore('#').substringBefore('?').lowercase() return imageFileExtensions.any { path.endsWith(it) } } /** * The URL to show the user for a link -- the zoom scheme is an internal detail, so a * long-press on a zoom-wrapped image offers actions on the real image URL. */ fun withoutImageZoomScheme(url: String): String = if (url.startsWith(IMAGE_ZOOM_SCHEME, ignoreCase = true)) url.substring(IMAGE_ZOOM_SCHEME.length) else url