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. --- .../readability/ReaderExtractorMathTest.kt | 159 +++++++++++++++++++++ 1 file changed, 159 insertions(+) create mode 100644 app/src/androidTest/java/org/terst/feedreader/readability/ReaderExtractorMathTest.kt (limited to 'app/src/androidTest/java/org/terst/feedreader') diff --git a/app/src/androidTest/java/org/terst/feedreader/readability/ReaderExtractorMathTest.kt b/app/src/androidTest/java/org/terst/feedreader/readability/ReaderExtractorMathTest.kt new file mode 100644 index 0000000..67b3bf1 --- /dev/null +++ b/app/src/androidTest/java/org/terst/feedreader/readability/ReaderExtractorMathTest.kt @@ -0,0 +1,159 @@ +package org.terst.feedreader.readability + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import kotlinx.coroutines.runBlocking +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import org.junit.After +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith + +/** + * Maths rendering during extraction ("we should render tex", reported against lcamtuf's + * Substack). + * + * What the live page actually ships -- confirmed by fetching + * https://blog.coredump.cx/p/yours-truly -- is TeX SOURCE, in a text node, inside a + * `
` wrapper: Substack renders + * it client-side with its own script, which never runs in this app. Readability keeps that + * text verbatim (it strips the class but not the text), so the reader showed the source. + * + * So extraction renders it: KaTeX (vendored in assets, never fetched) converts each + * expression to MathML before Readability runs, and the reader WebView -- still with + * JavaScript off -- renders the MathML natively. + * + * Needs a real WebView, so this is instrumented. Fixtures reproduce the exact markup shape + * from the live page rather than being a scraped copy of it (see ReaderExtractorFixtureTest + * for why). + */ +@RunWith(AndroidJUnit4::class) +class ReaderExtractorMathTest { + + private lateinit var server: MockWebServer + + @Before + fun startServer() { + server = MockWebServer() + server.start() + } + + @After + fun stopServer() { + server.shutdown() + } + + private fun extract(html: String): ExtractedArticle? { + server.enqueue(MockResponse().setBody(html).setResponseCode(200)) + val url = server.url("/article").toString() + val context = InstrumentationRegistry.getInstrumentation().targetContext + return runBlocking { ReaderExtractor.extract(context, url) } + } + + private val filler = + "Considered as a whole, an if-then statement cannot be true when its antecedent holds " + + "and its consequent does not, which is the whole of the trick being played here. " + + // Substack's shape, verbatim from the live page: inline \( \) delimiters in a text node, + // inside a block-styled span inside the latex-rendered wrapper. Note the align block is + // ALSO wrapped in inline delimiters -- KaTeX refuses {align} outside display mode, so + // rendering has to fall back to display mode rather than give up. + private val substackFixture = """ + Yours truly + +
+

Yours truly

+

Imagine the following seemingly innocent sentence:

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

$filler

+

A slice of the set may look the following way:

+
+
\(\begin{align} +t_{1012} &= \text{“Cindy weighs 12 pounds.”} \\ +t_{1013} &= \text{“Cindy doesn't weigh 13 pounds.”} +\end{align}\)
+
+

$filler

+
+ + """.trimIndent() + + // The other two conventional delimiter styles, and the one place maths must NOT be + // rendered: an article quoting TeX source on purpose, inside
.
+    private val delimiterFixture = """
+        Delimiter styles
+        
+        
+

Delimiter styles

+

$filler

+

The bracket form: \[ x_{1} + y^{2} \] and the dollar form: $$ a_{n} = b^{n} $$ in one paragraph.

+

$filler

+

To write that yourself, type:

+
\(E = mc^{2}\)
+

$filler

+
+ + """.trimIndent() + + @Test + fun substackTexSourceIsRenderedToMathMl() { + val result = extract(substackFixture) + + assertNotNull("extraction should succeed", result) + val content = result?.content.orEmpty() + assertTrue("both expressions should be rendered as MathML, got:\n$content", content.split(" is the point of the sentence and must stay source, got:\n$content", + content.contains("E = mc^{2}") + ) + } +} -- cgit v1.2.3