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("", ignoreCase = true) -> 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