package org.terst.feedreader.readability /** * Recognises a cached extraction that still holds raw TeX source instead of rendered maths. * * Needed for the same reason [ConsentNotice] is: fixing the extractor only changes what * FUTURE extractions produce. The reported article ("we should render tex", lcamtuf's * Substack) came with `extractedContent cached: true` -- its raw `\(p = \text{...}\)` * source was already on disk, and every open read it straight back out, so the reader * would have gone on showing TeX source no matter what the extractor learned to do. * Recognising it on read re-extracts the article the next time it is opened, including * everything extracted before maths rendering existed. * * Over-detecting is the real risk here, and it is a specific one: a false positive makes * an article re-extract on EVERY open, for ever, because re-extracting cannot change a * verdict the content itself causes. So the bar is a complete delimiter pair around * something that actually looks like an expression, and never anything that already * carries rendered MathML. */ object UnrenderedTex { /** * Length past which a delimiter pair is read as two unrelated delimiters rather than * one expression. Generous: the reported article's longest block (a ten-line `align` * environment) is well under half this. */ private const val MAX_EXPRESSION_LENGTH = 2_000 /** Delimiter pairs, matching the ones extraction renders (see ReaderExtractor). */ private val DELIMITERS = listOf("\\(" to "\\)", "\\[" to "\\]", "$$" to "$$") /** * Characters no plain sentence between two stray delimiters is likely to contain but * essentially every real expression does -- a command, a group, a sub- or superscript. */ private const val TEX_MARKERS = "\\{_^" /** * Quoted source, not maths: extraction deliberately leaves `
`/`` alone, so
     * an article demonstrating TeX would otherwise be convicted on every open and still,
     * correctly, come back unchanged.
     */
    private val CODE_BLOCK_REGEX = Regex(
        "<(pre|code)\\b[^>]*>.*?",
        setOf(RegexOption.IGNORE_CASE, RegexOption.DOT_MATCHES_ALL)
    )

    /**
     * True when [html] is an extraction whose maths never got rendered.
     *
     * Rendered maths anywhere in it means extraction already had its go at this page, and
     * nothing is to be gained by sending it round again: an expression KaTeX could not
     * render is still in there as source and always will be.
     */
    fun looksLikeUnrenderedTex(html: String?): Boolean {
        if (html.isNullOrBlank() || html.contains(" containsExpression(prose, open, close) }
    }

    private fun containsExpression(text: String, open: String, close: String): Boolean {
        var opensAt = text.indexOf(open)
        while (opensAt >= 0) {
            val innerStart = opensAt + open.length
            val closesAt = text.indexOf(close, innerStart)
            // No closing delimiter left in the content: whatever follows is prose that
            // happens to contain an opening one.
            if (closesAt < 0) break
            if (isExpression(text.substring(innerStart, closesAt))) return true
            opensAt = text.indexOf(open, innerStart)
        }
        return false
    }

    private fun isExpression(inner: String): Boolean =
        inner.isNotBlank() &&
            inner.length <= MAX_EXPRESSION_LENGTH &&
            // The span between two stray delimiters is markup, and markup brings its own
            // braces and underscores along (ids, filenames, inline styles). A tag anywhere
            // inside is on its own enough to say the two delimiters are unrelated.
            !inner.contains('<') &&
            inner.any { it in TEX_MARKERS }
}