This is an automated email from the ASF dual-hosted git repository.

tballison pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/tika.git


The following commit(s) were added to refs/heads/main by this push:
     new 4920528d50 TIKA-5056: write markdown math as a code span, not raw 
document text  Math went into markdown output as a raw node, so document text 
in a math span (#3561)
4920528d50 is described below

commit 4920528d5084136bbcb534f6899fc4797f942a5e
Author: Tim Allison <[email protected]>
AuthorDate: Fri Nov 6 14:59:57 2026 -0500

    TIKA-5056: write markdown math as a code span, not raw document text  Math 
went into markdown output as a raw node, so document text in a math span (#3561)
    
    (VLM output, or a PDF custom type or Word style named "Math") could become 
HTML
      or links. It is now written as $`...`$, which GitHub and GitLab render as 
math
      and no renderer turns into markup; only "math inline"/"math display" spans
      count as math. A randomized test renders the output and asserts no raw 
HTML,
      link or image survives.
    
    Co-authored-by: Claude Opus 5.5 <[email protected]>
---
 CHANGES.txt                                        |   6 +
 .../pages/configuration/inference-recipes.adoc     |   2 +-
 .../pages/configuration/parsers/vlm-parsers.adoc   |   6 +-
 docs/modules/ROOT/pages/security.adoc              |  11 ++
 .../apache/tika/sax/ToMarkdownContentHandler.java  |  51 +++++++--
 .../tika/sax/ToMarkdownContentHandlerTest.java     | 121 ++++++++++++++++++++-
 .../parser/vlm/MarkdownToXHTMLEmitterTest.java     |   6 +-
 7 files changed, 184 insertions(+), 19 deletions(-)

diff --git a/CHANGES.txt b/CHANGES.txt
index d228daca63..a5bfadb743 100644
--- a/CHANGES.txt
+++ b/CHANGES.txt
@@ -2,6 +2,12 @@ Release 4.3.0 - ???
 
   BREAKING CHANGES AND DEPRECATIONS
 
+   * Markdown output writes math as $`...`$ (a code span, which GitHub and 
GitLab
+     render as math) instead of raw $...$, so text in a math span can no 
longer become
+     HTML or links. Readers that only know $...$, such as pandoc, show it as 
code. Only
+     spans with class "math inline" or "math display" count as math; a PDF 
structure
+     type or Word style named "Math" is escaped like other text (TIKA-5056).
+
    * tika-ml-junkdetect has a new model and API. JunkDetector wraps a 
HashedJunkModel
      (junkdetect.bin, format JUNKHNG1; the old model format is not read). 
knownScripts(),
      scoreWithFeatureComponents() and the per-feature compute* methods are 
gone; use
diff --git a/docs/modules/ROOT/pages/configuration/inference-recipes.adoc 
b/docs/modules/ROOT/pages/configuration/inference-recipes.adoc
index bdbc77e4af..d1c40f722e 100644
--- a/docs/modules/ROOT/pages/configuration/inference-recipes.adoc
+++ b/docs/modules/ROOT/pages/configuration/inference-recipes.adoc
@@ -68,7 +68,7 @@ into XHTML, so a table is a `<table>` in every output format 
and a pipe table in
 HTML table if it has merged cells). Body
 cells come back as `<th>` and are passed through as such. Math comes back as 
LaTeX; Tika keeps it
 verbatim, delimiters included, in `<span class="math inline">` or `<span 
class="math display">`,
-and writes it unescaped in markdown. The load-time health check reads the
+and writes it in markdown as ``$`...`$``, verbatim inside a code span. The 
load-time health check reads the
 public `/v1/models`, so a wrong key passes startup and surfaces on the first 
request as the
 document's parse exception (HTTP 401 in the message).
 
diff --git a/docs/modules/ROOT/pages/configuration/parsers/vlm-parsers.adoc 
b/docs/modules/ROOT/pages/configuration/parsers/vlm-parsers.adoc
index 2b79d7c3ae..0b6de7a831 100644
--- a/docs/modules/ROOT/pages/configuration/parsers/vlm-parsers.adoc
+++ b/docs/modules/ROOT/pages/configuration/parsers/vlm-parsers.adoc
@@ -319,8 +319,10 @@ Superscripts and subscripts are `sup`/`sub` in XHTML and 
inline HTML in markdown
 (`x<sup>2</sup>`); plain-text output reads `x2`.
 
 TeX math (`$...$`, `$$...$$`, `\(...\)`, `\[...\]`) is kept verbatim, 
delimiters included, in
-`<span class="math inline">` or `<span class="math display">`, and written 
unescaped in markdown
-output. A `$` followed by a space, or a closing `$` followed by a digit, is 
not math, so prices are
+`<span class="math inline">` or `<span class="math display">`. Markdown output 
writes it as
+``$`...`$``, which GitHub and GitLab render as math: the code span keeps the 
TeX verbatim, and
+whatever the document put in it cannot become HTML or a link. Readers that 
only know `$...$`, such
+as pandoc, show it as code. Display math is written the same way, inline. A 
`$` followed by a space, or a closing `$` followed by a digit, is not math, so 
prices are
 left alone. Math containing a backtick or a blank line is not recognized.
 
 [#_per_request_configuration]
diff --git a/docs/modules/ROOT/pages/security.adoc 
b/docs/modules/ROOT/pages/security.adoc
index 4a481120e8..f5053db5ba 100644
--- a/docs/modules/ROOT/pages/security.adoc
+++ b/docs/modules/ROOT/pages/security.adoc
@@ -100,6 +100,17 @@ For the upgrade from the former `enableUnsecureFeatures` 
flag, which is now spli
 `allowPipes` and `allowPerRequestConfig`, see
 
xref:migration-to-4x/migrating-tika-server-4x.adoc#_pipes_and_async_require_allowpipes_per_request_config_requires_allowperrequestconfig[Migrating
 tika-server to 4.x].
 
+=== Extracted Output Is Document Content
+
+Text, links and image references in Tika's output come from the document, so 
treat them like
+any untrusted input before you display them. XHTML output escapes document 
text, and markdown
+output escapes it for CommonMark: document text does not become HTML or 
markdown structure there.
+Links and images the document itself contains are kept, in both formats, with 
their URLs as
+written. If you render the output, sanitize it the way you would any 
user-supplied HTML or
+markdown, including URL schemes such as `javascript:`. Markdown dialects with 
syntax of their own,
+such as MDX expressions or template-engine tags like `{{ }}`, are outside what 
the escaping
+covers.
+
 == Known Vulnerabilities
 
 Known CVEs in Apache Tika and their remediation are listed on the
diff --git 
a/tika-core/src/main/java/org/apache/tika/sax/ToMarkdownContentHandler.java 
b/tika-core/src/main/java/org/apache/tika/sax/ToMarkdownContentHandler.java
index ff4530db80..cd65a3e69c 100644
--- a/tika-core/src/main/java/org/apache/tika/sax/ToMarkdownContentHandler.java
+++ b/tika-core/src/main/java/org/apache/tika/sax/ToMarkdownContentHandler.java
@@ -833,18 +833,42 @@ public class ToMarkdownContentHandler extends 
DefaultHandler {
     }
 
     /**
-     * TeX goes out raw: the renderer's escaping ({@code \\mathcal}, {@code 
u\\_0}) breaks
-     * math-aware readers. In a table cell the renderer still escapes {@code 
|}, which GFM
-     * unescapes on read.
+     * TeX goes out as {@code $`...`$}, which GitHub and GitLab render as 
math: a code span keeps
+     * it verbatim ({@code \\mathcal}, {@code u_0}) yet cannot become HTML, a 
link or syntax of
+     * any other markdown dialect, whatever the document put in it. Text 
without TeX delimiters
+     * is escaped like any other text.
      */
     private void appendMath(String tex) {
-        if (tex.isEmpty()) {
+        String math = collapseLineBreaks(tex).strip();
+        if (math.isEmpty()) {
             return;
         }
         ensureInlineContainer();
-        HtmlInline raw = new HtmlInline();
-        raw.setLiteral(collapseLineBreaks(tex));
-        stack.peek().appendChild(raw);
+        String inner = stripTexDelimiters(math);
+        if (inner == null || inner.isBlank()) {
+            stack.peek().appendChild(new Text(math));
+            return;
+        }
+        Code code = new Code();
+        code.setLiteral(inner.strip());
+        stack.peek().appendChild(new Text("$"));
+        stack.peek().appendChild(code);
+        stack.peek().appendChild(new Text("$"));
+    }
+
+    /** The TeX inside {@code $$...$$}, {@code $...$}, {@code \\(...\\)} or 
{@code \\[...\\]}, else null. */
+    private static String stripTexDelimiters(String s) {
+        if (s.length() >= 4 && s.startsWith("$$") && s.endsWith("$$")) {
+            return s.substring(2, s.length() - 2);
+        }
+        if (s.length() >= 2 && s.startsWith("$") && s.endsWith("$")) {
+            return s.substring(1, s.length() - 1);
+        }
+        if (s.length() >= 4 && (s.startsWith("\\(") && s.endsWith("\\)")
+                || s.startsWith("\\[") && s.endsWith("\\]"))) {
+            return s.substring(2, s.length() - 2);
+        }
+        return null;
     }
 
     /**
@@ -879,16 +903,25 @@ public class ToMarkdownContentHandler extends 
DefaultHandler {
         return raw;
     }
 
+    /**
+     * Tika writes {@code math inline} or {@code math display}. Parsers that 
turn a document's
+     * style or structure names into a class (PDF custom types, Word styles) 
yield one token,
+     * so they cannot produce both.
+     */
     private static boolean isMathClass(String cls) {
         if (cls == null) {
             return false;
         }
+        boolean math = false;
+        boolean mode = false;
         for (String token : cls.trim().split("\\s+")) {
             if (token.equals("math")) {
-                return true;
+                math = true;
+            } else if (token.equals("inline") || token.equals("display")) {
+                mode = true;
             }
         }
-        return false;
+        return math && mode;
     }
 
     private static HtmlInline newBrInline() {
diff --git 
a/tika-core/src/test/java/org/apache/tika/sax/ToMarkdownContentHandlerTest.java 
b/tika-core/src/test/java/org/apache/tika/sax/ToMarkdownContentHandlerTest.java
index 57378411b7..22ecf02e0c 100644
--- 
a/tika-core/src/test/java/org/apache/tika/sax/ToMarkdownContentHandlerTest.java
+++ 
b/tika-core/src/test/java/org/apache/tika/sax/ToMarkdownContentHandlerTest.java
@@ -26,9 +26,14 @@ import java.io.StringWriter;
 import java.nio.charset.StandardCharsets;
 import java.util.Locale;
 import java.util.Random;
+import java.util.regex.Pattern;
 
+import org.commonmark.parser.Parser;
+import org.commonmark.renderer.html.HtmlRenderer;
 import org.junit.jupiter.api.RepeatedTest;
 import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
 import org.xml.sax.Attributes;
 import org.xml.sax.ContentHandler;
 import org.xml.sax.helpers.AttributesImpl;
@@ -525,11 +530,11 @@ public class ToMarkdownContentHandlerTest {
         endElement(handler, "p");
         handler.endDocument();
         String md = handler.toString();
-        assertContains("see u\\_0 and $u_0 \\in \\mathcal{H} *  x$", md);
+        assertContains("see u\\_0 and $`u_0 \\in \\mathcal{H} *  x`$", md);
     }
 
     @Test
-    public void testMathSpanInTableCellEscapesOnlyPipes() throws Exception {
+    public void testMathSpanInTableCellEscapesPipes() throws Exception {
         ToMarkdownContentHandler handler = new ToMarkdownContentHandler();
         handler.startDocument();
         script(handler, "table", "tr", "th", "#h1", "/th", "th", "#h2", "/th", 
"/tr", "tr", "td");
@@ -538,7 +543,7 @@ public class ToMarkdownContentHandlerTest {
         endElement(handler, "span");
         script(handler, "/td", "td", "#2", "/td", "/tr", "/table");
         handler.endDocument();
-        assertContains("|$\\\\|x\\\\|_1$|2|", handler.toString());
+        assertContains("|$`\\\\|x\\\\|_1`$|2|", handler.toString());
     }
 
     @Test
@@ -554,6 +559,114 @@ public class ToMarkdownContentHandlerTest {
         assertContains("u\\_0\\*", handler.toString());
     }
 
+    @Test
+    public void testMathSpanCannotOpenHtmlOrLinks() throws Exception {
+        String md = mathMarkdown("$a<b <img src=x onerror=alert(1)><!-- c --> 
<http://x.example>"
+                + " [x](javascript:alert(1))$");
+        String html = renderHtml(md);
+        assertFalse(html.contains("<img"), html);
+        assertFalse(html.contains("<!--"), html);
+        assertFalse(html.contains("href"), html);
+        assertContains("$`a<b <img", md);
+    }
+
+    @Test
+    public void testBackticksCannotCloseMathCodeSpan() throws Exception {
+        for (String tex : new String[]{"` <img src=x>", "a`b``c```d <img 
src=x>", "<img src=x> `"}) {
+            String html = renderHtml(mathMarkdown("$" + tex + "$"));
+            assertContains("<code>" + tex.strip().replace("<", 
"&lt;").replace(">", "&gt;") + "</code>",
+                    html);
+            assertFalse(html.contains("<img"), html);
+        }
+    }
+
+    @Test
+    public void testUndelimitedMathSpanIsEscaped() throws Exception {
+        String md = mathMarkdown("```u_0 \\", "<img src=x onerror=alert(1)>");
+        assertContains("u\\_0", md);
+        assertFalse(renderHtml(md).contains("<img"), md);
+        assertFalse(renderHtml(md).contains("<pre>"), md);
+    }
+
+    /**
+     * Whatever text and math spans hold, the markdown renders to no element a 
reader could
+     * act on: no raw HTML, comment, link or image.
+     */
+    @Test
+    public void testRandomMathSpansRenderNoHtmlOrLinks() throws Exception {
+        String[] pieces = {"<", ">", "[", "]", "(", ")", "!", ":", " ", "x", 
"img src=x",
+                "javascript:alert(1)", "http://e.x";, "\\", "$", "\\(", "\\)", 
"\\[", "\\]", "`",
+                "```", "*", "&", "#", "-", "\"", "|", "<!--", "]:"};
+        Pattern acted = Pattern.compile(
+                "<(?!/?(p|code|em|strong|blockquote|ul|ol|li|pre|h[1-6])>|br 
?/?>|hr ?/?>"
+                        + "|ol start=\"[0-9]+\">|pre><code 
class=\"language-)");
+        long seed = new Random().nextLong();
+        Random r = new Random(seed);
+        for (int iter = 0; iter < 5000; iter++) {
+            ToMarkdownContentHandler handler = new ToMarkdownContentHandler();
+            handler.startDocument();
+            startElement(handler, "p");
+            for (int k = 1 + r.nextInt(3); k > 0; k--) {
+                chars(handler, randomText(r, pieces, r.nextInt(4)));
+                startElement(handler, "span", "class", "math inline");
+                String tex = randomText(r, pieces, 1 + r.nextInt(8));
+                chars(handler, r.nextBoolean() ? "$" + tex + "$" : tex);
+                endElement(handler, "span");
+            }
+            endElement(handler, "p");
+            handler.endDocument();
+            String md = handler.toString();
+            String html = renderHtml(md);
+            assertFalse(acted.matcher(html).find(), "seed=" + seed + " md=" + 
md + " html=" + html);
+        }
+    }
+
+    private static String randomText(Random r, String[] pieces, int n) {
+        StringBuilder sb = new StringBuilder();
+        for (int i = 0; i < n; i++) {
+            sb.append(pieces[r.nextInt(pieces.length)]);
+        }
+        return sb.toString();
+    }
+
+    private static String mathMarkdown(String tex) throws Exception {
+        return mathMarkdown(tex, "");
+    }
+
+    private static String mathMarkdown(String tex, String after) throws 
Exception {
+        ToMarkdownContentHandler handler = new ToMarkdownContentHandler();
+        handler.startDocument();
+        startElement(handler, "p");
+        startElement(handler, "span", "class", "math inline");
+        chars(handler, tex);
+        endElement(handler, "span");
+        if (!after.isEmpty()) {
+            chars(handler, after);
+        }
+        endElement(handler, "p");
+        handler.endDocument();
+        return handler.toString();
+    }
+
+    private static String renderHtml(String md) {
+        return 
HtmlRenderer.builder().build().render(Parser.builder().build().parse(md));
+    }
+
+    // PDF custom structure types and Word style names become a single class 
token
+    @ParameterizedTest
+    @ValueSource(strings = {"math", "math_inline", "math-display"})
+    public void testDocumentDerivedMathClassIsEscaped(String cls) throws 
Exception {
+        ToMarkdownContentHandler handler = new ToMarkdownContentHandler();
+        handler.startDocument();
+        startElement(handler, "p");
+        startElement(handler, "span", "class", cls);
+        chars(handler, "$u_0$");
+        endElement(handler, "span");
+        endElement(handler, "p");
+        handler.endDocument();
+        assertContains("u\\_0", handler.toString());
+    }
+
     @Test
     public void testEmphasisEdgeWhitespaceAndEmptyDropped() throws Exception {
         ToMarkdownContentHandler handler = new ToMarkdownContentHandler();
@@ -661,7 +774,7 @@ public class ToMarkdownContentHandlerTest {
         chars(handler, " is small");
         endElement(handler, "p");
         handler.endDocument();
-        assertContains("$x$ is small", handler.toString());
+        assertContains("$`x`$ is small", handler.toString());
     }
 
     @Test
diff --git 
a/tika-parsers/tika-parsers-ml/tika-vlm/src/test/java/org/apache/tika/parser/vlm/MarkdownToXHTMLEmitterTest.java
 
b/tika-parsers/tika-parsers-ml/tika-vlm/src/test/java/org/apache/tika/parser/vlm/MarkdownToXHTMLEmitterTest.java
index 0b167a02d9..4caa166dcd 100644
--- 
a/tika-parsers/tika-parsers-ml/tika-vlm/src/test/java/org/apache/tika/parser/vlm/MarkdownToXHTMLEmitterTest.java
+++ 
b/tika-parsers/tika-parsers-ml/tika-vlm/src/test/java/org/apache/tika/parser/vlm/MarkdownToXHTMLEmitterTest.java
@@ -411,9 +411,9 @@ public class MarkdownToXHTMLEmitterTest {
         MarkdownToXHTMLEmitter.emit(md, out);
         out.endDocument();
         String written = out.toString();
-        assertContains("norm $\\|\\cdot\\|$ with $u_0 \\in \\mathcal{H}$", 
written);
-        // GFM unescapes \| in a cell before math, so a renderer sees $\|x\|$
-        assertContains("|$\\\\|x\\\\|$|2|", written);
+        assertContains("norm $`\\|\\cdot\\|`$ with $`u_0 \\in \\mathcal{H}`$", 
written);
+        // GFM unescapes \| in a cell before the code span, so a renderer sees 
\|x\|
+        assertContains("|$`\\\\|x\\\\|`$|2|", written);
     }
 
     @Test

Reply via email to