From e50f4fbbca21b85648f6c4045cbd50f672d315cd Mon Sep 17 00:00:00 2001 From: Peter Stone Date: Fri, 16 Oct 2026 21:04:38 +0000 Subject: Render an article's maths instead of showing its TeX source Reported against lcamtuf's Substack ("we should render tex", blog.coredump.cx/p/yours-truly): reader mode showed raw "\(p = \text{...}\)" where equations should be. What the page actually ships is the TeX SOURCE, as text, inside a "latex-rendered" div -- Substack renders it in the browser with its own script, which never runs here. Readability keeps that text verbatim (it strips the class, not the text), so nothing was being discarded during extraction and no amount of extraction-side repair would have helped: there was simply never any rendered markup to keep. So render it during extraction, where JavaScript is already running for Readability's sake. KaTeX (vendored in assets, never fetched -- this runs against untrusted third-party pages) converts each expression to MathML before Readability sees the document, and the reader WebView renders that natively. The reader keeps JavaScript OFF, as it must, and needs no stylesheet, webfont or script of KaTeX's; the only reader-side change is containing a wide equation so it scrolls within its own box instead of dragging the whole article sideways. Two details worth knowing: - KaTeX refuses {align} outside display mode, and Substack wraps display maths in INLINE delimiters (a block-styled span does the centring), so a mode that renders nothing is retried in the other one. An expression neither mode can render is left as source -- no worse than before. - The reported article came with "extractedContent cached: true", so the raw source was already on disk and every open read it back. Recognised and dropped on read (same mechanism as the cached consent notice), which re-extracts the article the next time it is opened. APK grows by ~90KB: the vendored katex.min.js (267KB) compresses to 91KB in the APK. No new permissions, no schema change, no new JS-enabled surface. Verified on the emulator against the live article: stale raw-TeX cache discarded, article re-extracted, all four equations rendered, and a wide align block scrolls inside itself. --- .../feedreader/readability/UnrenderedTexTest.kt | 116 +++++++++++++++++++++ 1 file changed, 116 insertions(+) create mode 100644 app/src/test/java/org/terst/feedreader/readability/UnrenderedTexTest.kt (limited to 'app/src/test/java/org/terst/feedreader/readability/UnrenderedTexTest.kt') diff --git a/app/src/test/java/org/terst/feedreader/readability/UnrenderedTexTest.kt b/app/src/test/java/org/terst/feedreader/readability/UnrenderedTexTest.kt new file mode 100644 index 0000000..016b148 --- /dev/null +++ b/app/src/test/java/org/terst/feedreader/readability/UnrenderedTexTest.kt @@ -0,0 +1,116 @@ +package org.terst.feedreader.readability + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Recognising a cached extraction that still holds raw TeX source. + * + * Substack ships maths as TeX source text in the page (`\(p = \text{...}\)` inside a + * `latex-rendered` wrapper) and renders it client-side with its own script, which never + * runs in this app. Extraction now converts that source to MathML before Readability sees + * it -- but an article extracted BEFORE that existed has the raw source sitting in + * `articles.extractedContent`, and every subsequent open reads it straight back out. The + * reported article came with "extractedContent cached: true", so this is the half a fixed + * extractor cannot reach. + * + * Over-detecting is the real risk: a false positive makes the article re-extract on every + * single open, since re-extracting cannot change a verdict the content itself causes. So + * the bar is a complete delimiter pair around something that actually looks like TeX, and + * never anything already carrying rendered MathML. + */ +class UnrenderedTexTest { + + // "$$" cannot be written inline in a Kotlin string followed by a letter -- that parses + // as a template expression. + private val dollars = "$" + "$" + + @Test + fun `substack's inline-delimited tex source is recognised`() { + assertTrue( + UnrenderedTex.looksLikeUnrenderedTex( + "

" + + "\\(p = \\text{“if }p\\text{ is true, then pigs can fly”}\\)

" + ) + ) + } + + @Test + fun `bracket-delimited display tex source is recognised`() { + assertTrue(UnrenderedTex.looksLikeUnrenderedTex("

\\[\\begin{align} a &= b \\end{align}\\]

")) + } + + @Test + fun `double-dollar display tex source is recognised`() { + assertTrue(UnrenderedTex.looksLikeUnrenderedTex("

" + dollars + "x_{1} + y^{2}" + dollars + "

")) + } + + @Test + fun `content already carrying rendered mathml is left alone`() { + assertFalse( + UnrenderedTex.looksLikeUnrenderedTex( + "

p

" + ) + ) + } + + // An expression KaTeX could not render stays in the output as source. If the presence of + // any source at all convicted the whole article, that article would re-extract on every + // open, for ever, and never come out any different. Rendered maths anywhere in the + // content is proof the extraction already had its go at it. + @Test + fun `a leftover unrenderable expression alongside rendered mathml does not convict`() { + assertFalse( + UnrenderedTex.looksLikeUnrenderedTex( + "

a

\\(\\thisIsNotRealTex{}\\)

" + ) + ) + } + + @Test + fun `ordinary prose is not tex`() { + assertFalse( + UnrenderedTex.looksLikeUnrenderedTex( + "

The repair bill came to $$ in the end; the second quote was $$ as well, and (as it " + + "turned out) the warranty had lapsed anyway.

" + ) + ) + } + + // The inner span between two stray delimiters is HTML, not one expression -- markup + // brings its own underscores and braces along (ids, filenames, inline styles), so a tag + // anywhere inside is on its own enough to say these two delimiters are unrelated. + @Test + fun `delimiters spanning real markup are not one expression`() { + assertFalse( + UnrenderedTex.looksLikeUnrenderedTex( + "

It cost $$ for the part, " + + "and $$ again for the labour.

" + ) + ) + } + + @Test + fun `an unclosed delimiter is not tex`() { + assertFalse(UnrenderedTex.looksLikeUnrenderedTex("

Then he wrote \\(x, trailed off, and left.

")) + } + + // Extraction deliberately skips
/ when rendering maths -- an article ABOUT
+    // TeX quotes the source on purpose. Convicting on that would re-extract it on every
+    // open and still, correctly, change nothing.
+    @Test
+    fun `tex quoted inside a code block is not a stale extraction`() {
+        assertFalse(
+            UnrenderedTex.looksLikeUnrenderedTex(
+                "

Write it like this:

\\(x^{2} + y^{2}\\)
" + ) + ) + } + + @Test + fun `nothing cached is not tex`() { + assertFalse(UnrenderedTex.looksLikeUnrenderedTex(null)) + assertFalse(UnrenderedTex.looksLikeUnrenderedTex(" ")) + } +} -- cgit v1.2.3