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 024ecefc1f TIKA-5011: keep LaTeX math verbatim in VLM Markdown output
(#3455)
024ecefc1f is described below
commit 024ecefc1f0bb9643664275f1c73f6d4020c1108
Author: Tim Allison <[email protected]>
AuthorDate: Fri Oct 30 11:43:43 2026 -0400
TIKA-5011: keep LaTeX math verbatim in VLM Markdown output (#3455)
---
CHANGES.txt | 8 +
.../pages/configuration/inference-recipes.adoc | 4 +-
.../apache/tika/sax/ToMarkdownContentHandler.java | 51 ++++
.../tika/sax/ToMarkdownContentHandlerTest.java | 45 ++++
.../tika/parser/vlm/MarkdownToXHTMLEmitter.java | 47 +++-
.../java/org/apache/tika/parser/vlm/MathSpans.java | 287 +++++++++++++++++++++
.../parser/vlm/MarkdownToXHTMLEmitterTest.java | 93 +++++++
7 files changed, 521 insertions(+), 14 deletions(-)
diff --git a/CHANGES.txt b/CHANGES.txt
index 2a8e7586d8..d010544521 100644
--- a/CHANGES.txt
+++ b/CHANGES.txt
@@ -44,6 +44,14 @@ Release 4.3.0 - ???
async emitter threads) shared one connection and prepared statement
unguarded,
mixing values across rows and committing each other's batches (TIKA-5007).
+ EXTRACTION AND FORMATS
+
+ * VLM text recognizers (jina-ocr, OpenAI-compatible, Claude, Gemini) no
longer corrupt
+ LaTeX math in model output: $...$, $$...$$, \(...\) and \[...\] are kept
verbatim
+ instead of being read as Markdown, which dropped the backslash from \{,
\} and \|
+ and split GFM table cells on |. Math is emitted as <span class="math
inline"> or
+ <span class="math display">, and the Markdown writer outputs it unescaped
(TIKA-5011).
+
OTHER CHANGES
* A runtime fetcher or emitter save or delete reaches running pipes workers
at
diff --git a/docs/modules/ROOT/pages/configuration/inference-recipes.adoc
b/docs/modules/ROOT/pages/configuration/inference-recipes.adoc
index a5045a51e7..2acebc7204 100644
--- a/docs/modules/ROOT/pages/configuration/inference-recipes.adoc
+++ b/docs/modules/ROOT/pages/configuration/inference-recipes.adoc
@@ -65,7 +65,9 @@ text is poor. The key comes from the environment
The model returns markdown with tables as raw HTML, whatever the prompt says;
Tika parses both
into XHTML, so a table is a `<table>` in every output format and a pipe table
in markdown. Body
-cells come back as `<th>` and are passed through as such. The load-time health
check reads the
+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
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/tika-core/src/main/java/org/apache/tika/sax/ToMarkdownContentHandler.java
b/tika-core/src/main/java/org/apache/tika/sax/ToMarkdownContentHandler.java
index 8be96fd00a..42469f6cd2 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
@@ -45,6 +45,7 @@ import org.commonmark.node.Emphasis;
import org.commonmark.node.FencedCodeBlock;
import org.commonmark.node.HardLineBreak;
import org.commonmark.node.Heading;
+import org.commonmark.node.HtmlInline;
import org.commonmark.node.Image;
import org.commonmark.node.Link;
import org.commonmark.node.ListBlock;
@@ -105,6 +106,10 @@ public class ToMarkdownContentHandler extends
DefaultHandler {
private Code inlineCode;
private StringBuilder inlineCodeText;
+ // Buffer for <span class="math ..."> TeX, written verbatim; depth counts
nested spans.
+ private StringBuilder mathText;
+ private int mathSpanDepth;
+
// Buffer for <pre> fenced code block literal.
private FencedCodeBlock codeBlock;
private StringBuilder codeBlockText;
@@ -253,6 +258,15 @@ public class ToMarkdownContentHandler extends
DefaultHandler {
ensureInlineContainer();
stack.peek().appendChild(new HardLineBreak());
break;
+ case "span":
+ if (mathText != null) {
+ mathSpanDepth++;
+ } else if (codeBlockText == null && inlineCodeText == null
+ && isMathClass(atts.getValue("class"))) {
+ mathText = new StringBuilder();
+ mathSpanDepth = 1;
+ }
+ break;
case "img":
ensureInlineContainer();
Image img = new Image(value(atts, "src"), null);
@@ -330,6 +344,12 @@ public class ToMarkdownContentHandler extends
DefaultHandler {
case "div":
closeImplicitParagraph();
break;
+ case "span":
+ if (mathText != null && --mathSpanDepth == 0) {
+ appendMath(mathText.toString());
+ mathText = null;
+ }
+ break;
case "table":
endTable();
break;
@@ -363,6 +383,10 @@ public class ToMarkdownContentHandler extends
DefaultHandler {
inlineCodeText.append(ch, start, length);
return;
}
+ if (mathText != null) {
+ mathText.append(ch, start, length);
+ return;
+ }
String text = new String(ch, start, length);
if (text.trim().isEmpty()) {
@@ -637,6 +661,33 @@ public class ToMarkdownContentHandler extends
DefaultHandler {
stack.push(cell);
}
+ /**
+ * 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.
+ */
+ private void appendMath(String tex) {
+ if (tex.isEmpty()) {
+ return;
+ }
+ ensureInlineContainer();
+ HtmlInline raw = new HtmlInline();
+ raw.setLiteral(collapseLineBreaks(tex));
+ stack.peek().appendChild(raw);
+ }
+
+ private static boolean isMathClass(String cls) {
+ if (cls == null) {
+ return false;
+ }
+ for (String token : cls.trim().split("\\s+")) {
+ if (token.equals("math")) {
+ return true;
+ }
+ }
+ return false;
+ }
+
private static BulletList newBulletList() {
BulletList list = new BulletList();
list.setMarker("-");
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 2b060e504d..90456834e4 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
@@ -500,6 +500,51 @@ public class ToMarkdownContentHandlerTest {
assertTrue(handler.toString().contains("Line one \nLine two"));
}
+ @Test
+ public void testMathSpanWrittenVerbatim() throws Exception {
+ ToMarkdownContentHandler handler = new ToMarkdownContentHandler();
+ handler.startDocument();
+ startElement(handler, "p");
+ chars(handler, "see u_0 and ");
+ startElement(handler, "span", "class", "math inline");
+ chars(handler, "$u_0 \\in ");
+ startElement(handler, "span");
+ chars(handler, "\\mathcal{H} *");
+ endElement(handler, "span");
+ chars(handler, "\n x$");
+ endElement(handler, "span");
+ endElement(handler, "p");
+ handler.endDocument();
+ String md = handler.toString();
+ assertContains("see u\\_0 and $u_0 \\in \\mathcal{H} * x$", md);
+ }
+
+ @Test
+ public void testMathSpanInTableCellEscapesOnlyPipes() throws Exception {
+ ToMarkdownContentHandler handler = new ToMarkdownContentHandler();
+ handler.startDocument();
+ script(handler, "table", "tr", "th", "#h1", "/th", "th", "#h2", "/th",
"/tr", "tr", "td");
+ startElement(handler, "span", "class", "math inline");
+ chars(handler, "$\\|x\\|_1$");
+ endElement(handler, "span");
+ script(handler, "/td", "td", "#2", "/td", "/tr", "/table");
+ handler.endDocument();
+ assertContains("|$\\\\|x\\\\|_1$|2|", handler.toString());
+ }
+
+ @Test
+ public void testNonMathSpanStillEscaped() throws Exception {
+ ToMarkdownContentHandler handler = new ToMarkdownContentHandler();
+ handler.startDocument();
+ startElement(handler, "p");
+ startElement(handler, "span", "class", "mathematics");
+ chars(handler, "u_0*");
+ endElement(handler, "span");
+ endElement(handler, "p");
+ handler.endDocument();
+ assertContains("u\\_0\\*", handler.toString());
+ }
+
@Test
public void testBoldInsideListItem() throws Exception {
ToMarkdownContentHandler handler = new ToMarkdownContentHandler();
diff --git
a/tika-parsers/tika-parsers-ml/tika-vlm/src/main/java/org/apache/tika/parser/vlm/MarkdownToXHTMLEmitter.java
b/tika-parsers/tika-parsers-ml/tika-vlm/src/main/java/org/apache/tika/parser/vlm/MarkdownToXHTMLEmitter.java
index 9c1073ce65..b2a047d44d 100644
---
a/tika-parsers/tika-parsers-ml/tika-vlm/src/main/java/org/apache/tika/parser/vlm/MarkdownToXHTMLEmitter.java
+++
b/tika-parsers/tika-parsers-ml/tika-vlm/src/main/java/org/apache/tika/parser/vlm/MarkdownToXHTMLEmitter.java
@@ -82,6 +82,9 @@ import org.xml.sax.helpers.AttributesImpl;
* attributes dropped except {@code colspan}/{@code rowspan}; unknown
elements
* contribute their text. Inline HTML tags are
* dropped (their text is kept), except {@code <br>}.</li>
+ * <li>TeX math ({@code $...$}, {@code $$...$$}, {@code \(...\)}, {@code
\[...\]}):
+ * kept verbatim, delimiters included, in {@code <span class="math
inline">} or
+ * {@code <span class="math display">} (see {@link MathSpans}).</li>
* </ul>
*
* @since Apache Tika 4.0
@@ -133,8 +136,9 @@ class MarkdownToXHTMLEmitter {
if (markdown == null || markdown.isEmpty()) {
return;
}
- Node document = PARSER.parse(markdown);
- SAXVisitor visitor = new SAXVisitor(handler);
+ MathSpans math = MathSpans.protect(markdown);
+ Node document = PARSER.parse(math.text());
+ SAXVisitor visitor = new SAXVisitor(handler, math);
document.accept(visitor);
if (visitor.saxException != null) {
throw visitor.saxException;
@@ -147,10 +151,12 @@ class MarkdownToXHTMLEmitter {
private static class SAXVisitor extends AbstractVisitor {
private final ContentHandler handler;
+ private final MathSpans math;
SAXException saxException;
- SAXVisitor(ContentHandler handler) {
+ SAXVisitor(ContentHandler handler, MathSpans math) {
this.handler = handler;
+ this.math = math;
}
// --- block nodes ---
@@ -221,7 +227,7 @@ class MarkdownToXHTMLEmitter {
}
startElement("pre");
startElement("code", attrs);
- characters(fencedCodeBlock.getLiteral());
+ characters(math.restore(fencedCodeBlock.getLiteral()));
endElement("code");
endElement("pre");
}
@@ -230,7 +236,7 @@ class MarkdownToXHTMLEmitter {
public void visit(IndentedCodeBlock indentedCodeBlock) {
startElement("pre");
startElement("code");
- characters(indentedCodeBlock.getLiteral());
+ characters(math.restore(indentedCodeBlock.getLiteral()));
endElement("code");
endElement("pre");
}
@@ -257,7 +263,7 @@ class MarkdownToXHTMLEmitter {
if (node instanceof TextNode) {
String text = ((TextNode) node).getWholeText();
if (!(inStructure && text.isBlank())) {
- characters(text);
+ textWithMath(text);
}
return;
}
@@ -303,7 +309,7 @@ class MarkdownToXHTMLEmitter {
@Override
public void visit(Text text) {
- characters(text.getLiteral());
+ textWithMath(text.getLiteral());
}
@Override
@@ -323,16 +329,16 @@ class MarkdownToXHTMLEmitter {
@Override
public void visit(Code code) {
startElement("code");
- characters(code.getLiteral());
+ characters(math.restore(code.getLiteral()));
endElement("code");
}
@Override
public void visit(Link link) {
AttributesImpl attrs = new AttributesImpl();
- attrs.addAttribute("", "href", "href", "CDATA",
link.getDestination());
+ attrs.addAttribute("", "href", "href", "CDATA",
math.restore(link.getDestination()));
if (link.getTitle() != null && !link.getTitle().isEmpty()) {
- attrs.addAttribute("", "title", "title", "CDATA",
link.getTitle());
+ attrs.addAttribute("", "title", "title", "CDATA",
math.restore(link.getTitle()));
}
startElement("a", attrs);
visitChildren(link);
@@ -342,9 +348,9 @@ class MarkdownToXHTMLEmitter {
@Override
public void visit(Image image) {
AttributesImpl attrs = new AttributesImpl();
- attrs.addAttribute("", "src", "src", "CDATA",
image.getDestination());
+ attrs.addAttribute("", "src", "src", "CDATA",
math.restore(image.getDestination()));
if (image.getTitle() != null && !image.getTitle().isEmpty()) {
- attrs.addAttribute("", "title", "title", "CDATA",
image.getTitle());
+ attrs.addAttribute("", "title", "title", "CDATA",
math.restore(image.getTitle()));
}
// Use alt text from child text nodes
StringBuilder alt = new StringBuilder();
@@ -355,7 +361,7 @@ class MarkdownToXHTMLEmitter {
}
child = child.getNext();
}
- attrs.addAttribute("", "alt", "alt", "CDATA", alt.toString());
+ attrs.addAttribute("", "alt", "alt", "CDATA",
math.restore(alt.toString()));
emptyElement("img", attrs);
}
@@ -443,6 +449,21 @@ class MarkdownToXHTMLEmitter {
}
}
+ private void textWithMath(String text) {
+ for (MathSpans.Piece piece : math.split(text)) {
+ if (!piece.math) {
+ characters(piece.text);
+ continue;
+ }
+ AttributesImpl attrs = new AttributesImpl();
+ attrs.addAttribute("", "class", "class", "CDATA",
+ piece.display ? "math display" : "math inline");
+ startElement("span", attrs);
+ characters(piece.text);
+ endElement("span");
+ }
+ }
+
// --- SAX helpers ---
private void startElement(String localName) {
diff --git
a/tika-parsers/tika-parsers-ml/tika-vlm/src/main/java/org/apache/tika/parser/vlm/MathSpans.java
b/tika-parsers/tika-parsers-ml/tika-vlm/src/main/java/org/apache/tika/parser/vlm/MathSpans.java
new file mode 100644
index 0000000000..d73f5d33e1
--- /dev/null
+++
b/tika-parsers/tika-parsers-ml/tika-vlm/src/main/java/org/apache/tika/parser/vlm/MathSpans.java
@@ -0,0 +1,287 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.tika.parser.vlm;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Shields TeX math in model output from the Markdown parser.
+ * <p>
+ * CommonMark has no math syntax: inside {@code $\|x\|$} it reads {@code \|}
as an
+ * escaped pipe and drops the backslash, and a GFM table splits cells on the
pipes.
+ * {@link #protect} swaps each math span for a placeholder before parsing;
+ * {@link #restore} and {@link #split} put the original text back afterwards.
+ * <p>
+ * Delimiters: {@code $...$}, {@code $$...$$}, {@code \(...\)}, {@code
\[...\]}.
+ * {@code $...$} follows pandoc's guards so prices are not math: the opening
+ * {@code $} must be followed by a non-space, the closing one preceded by a
non-space
+ * and not followed by a digit. No span crosses a blank line or a backtick,
and code
+ * spans and fenced code blocks are left alone.
+ */
+final class MathSpans {
+
+ static final char OPEN = '';
+ static final char CLOSE = '';
+
+ /** One piece of text after {@link #split}: plain text, or a math span
with delimiters. */
+ static final class Piece {
+ final String text;
+ final boolean math;
+ final boolean display;
+
+ Piece(String text, boolean math, boolean display) {
+ this.text = text;
+ this.math = math;
+ this.display = display;
+ }
+ }
+
+ private final String text;
+ private final List<String> spans;
+
+ private MathSpans(String text, List<String> spans) {
+ this.text = text;
+ this.spans = spans;
+ }
+
+ /** The input with each math span replaced by a placeholder. */
+ String text() {
+ return text;
+ }
+
+ static MathSpans protect(String s) {
+ List<String> spans = new ArrayList<>();
+ if (s.indexOf(OPEN) >= 0 || s.indexOf(CLOSE) >= 0
+ || (s.indexOf('$') < 0 && s.indexOf('\\') < 0)) {
+ return new MathSpans(s, spans);
+ }
+ StringBuilder out = new StringBuilder(s.length());
+ // a failed search for a closer stops at a boundary; openers before it
fail too
+ int[] failedUntil = new int[4];
+ int n = s.length();
+ int i = 0;
+ while (i < n) {
+ if (i == 0 || s.charAt(i - 1) == '\n') {
+ int fenceEnd = skipFencedBlock(s, i);
+ if (fenceEnd > i) {
+ out.append(s, i, fenceEnd);
+ i = fenceEnd;
+ continue;
+ }
+ }
+ char c = s.charAt(i);
+ int end = -1;
+ if (c == '`') {
+ int skip = skipCodeSpan(s, i);
+ out.append(s, i, skip);
+ i = skip;
+ continue;
+ } else if (c == '\\' && i + 1 < n) {
+ char next = s.charAt(i + 1);
+ if (next == '(' || next == '[') {
+ int kind = next == '(' ? 0 : 1;
+ if (i >= failedUntil[kind]) {
+ end = findCloser(s, i + 2, next == '(' ? ')' : ']',
kind, failedUntil);
+ }
+ }
+ if (end < 0) {
+ out.append(s, i, i + 2);
+ i += 2;
+ continue;
+ }
+ } else if (c == '$') {
+ if (i + 1 < n && s.charAt(i + 1) == '$') {
+ if (i >= failedUntil[2]) {
+ end = findCloser(s, i + 2, '$', 2, failedUntil);
+ }
+ if (end < 0) {
+ out.append("$$");
+ i += 2;
+ continue;
+ }
+ } else if (i + 1 < n && !Character.isWhitespace(s.charAt(i +
1))
+ && i >= failedUntil[3]) {
+ end = findCloser(s, i + 1, '$', 3, failedUntil);
+ }
+ }
+ if (end < 0) {
+ out.append(c);
+ i++;
+ continue;
+ }
+ out.append(OPEN).append(spans.size()).append(CLOSE);
+ spans.add(s.substring(i, end));
+ i = end;
+ }
+ return new MathSpans(out.toString(), spans);
+ }
+
+ /**
+ * Finds the end (exclusive) of a math span whose content starts at {@code
from}.
+ * kind: 0 {@code \)}, 1 {@code \]}, 2 {@code $$}, 3 {@code $}. Returns
-1, and
+ * records the boundary in {@code failedUntil}, when there is none.
+ */
+ private static int findCloser(String s, int from, char close, int kind,
int[] failedUntil) {
+ int n = s.length();
+ int i = from;
+ while (i < n) {
+ char c = s.charAt(i);
+ if (c == '`' || (c == '\n' && isBlankLineAhead(s, i + 1))) {
+ break;
+ }
+ if (c == '\\') {
+ if (kind <= 1 && i + 1 < n && s.charAt(i + 1) == close) {
+ return i > from ? i + 2 : -1;
+ }
+ i += 2;
+ continue;
+ }
+ if (c == '$') {
+ if (kind == 2) {
+ if (i + 1 < n && s.charAt(i + 1) == '$' && i > from) {
+ return i + 2;
+ }
+ } else if (kind == 3) {
+ boolean afterNonSpace = !Character.isWhitespace(s.charAt(i
- 1));
+ boolean digitNext = i + 1 < n &&
Character.isDigit(s.charAt(i + 1));
+ if (afterNonSpace && !digitNext) {
+ return i + 1;
+ }
+ }
+ }
+ i++;
+ }
+ failedUntil[kind] = i;
+ return -1;
+ }
+
+ private static boolean isBlankLineAhead(String s, int i) {
+ while (i < s.length() && (s.charAt(i) == ' ' || s.charAt(i) == '\t')) {
+ i++;
+ }
+ return i >= s.length() || s.charAt(i) == '\n';
+ }
+
+ /** End of a backtick code span starting at i, or the end of the backtick
run. */
+ private static int skipCodeSpan(String s, int i) {
+ int run = runLength(s, i, '`');
+ int j = i + run;
+ while (j < s.length()) {
+ int k = s.indexOf('`', j);
+ if (k < 0) {
+ break;
+ }
+ int len = runLength(s, k, '`');
+ if (len == run) {
+ return k + len;
+ }
+ j = k + len;
+ }
+ return i + run;
+ }
+
+ /** End of a fenced code block opening at line start i, or i if there is
none. */
+ private static int skipFencedBlock(String s, int i) {
+ int j = i;
+ while (j < s.length() && j - i < 3 && s.charAt(j) == ' ') {
+ j++;
+ }
+ if (j >= s.length() || (s.charAt(j) != '`' && s.charAt(j) != '~')) {
+ return i;
+ }
+ char fence = s.charAt(j);
+ int len = runLength(s, j, fence);
+ if (len < 3) {
+ return i;
+ }
+ int line = s.indexOf('\n', j);
+ while (line >= 0) {
+ int k = line + 1;
+ while (k < s.length() && k - line - 1 < 3 && s.charAt(k) == ' ') {
+ k++;
+ }
+ if (runLength(s, k, fence) >= len) {
+ int eol = s.indexOf('\n', k);
+ return eol < 0 ? s.length() : eol + 1;
+ }
+ line = s.indexOf('\n', k);
+ }
+ return s.length();
+ }
+
+ private static int runLength(String s, int i, char c) {
+ int j = i;
+ while (j < s.length() && s.charAt(j) == c) {
+ j++;
+ }
+ return j - i;
+ }
+
+ /** Replaces placeholders in a literal with the original math text. */
+ String restore(String s) {
+ if (s == null || spans.isEmpty() || s.indexOf(OPEN) < 0) {
+ return s;
+ }
+ StringBuilder sb = new StringBuilder(s.length());
+ for (Piece p : split(s)) {
+ sb.append(p.text);
+ }
+ return sb.toString();
+ }
+
+ /** Splits text at placeholders into plain and math pieces. */
+ List<Piece> split(String s) {
+ List<Piece> pieces = new ArrayList<>();
+ int start = 0;
+ int i = spans.isEmpty() ? -1 : s.indexOf(OPEN);
+ while (i >= 0) {
+ int close = s.indexOf(CLOSE, i);
+ int index = close < 0 ? -1 : parseIndex(s, i + 1, close);
+ if (index < 0) {
+ i = s.indexOf(OPEN, i + 1);
+ continue;
+ }
+ if (i > start) {
+ pieces.add(new Piece(s.substring(start, i), false, false));
+ }
+ String math = spans.get(index);
+ pieces.add(new Piece(math, true, math.startsWith("$$") ||
math.startsWith("\\[")));
+ start = close + 1;
+ i = s.indexOf(OPEN, start);
+ }
+ if (start < s.length()) {
+ pieces.add(new Piece(s.substring(start), false, false));
+ }
+ return pieces;
+ }
+
+ private int parseIndex(String s, int from, int to) {
+ if (to <= from || to - from > 9) {
+ return -1;
+ }
+ int v = 0;
+ for (int k = from; k < to; k++) {
+ char c = s.charAt(k);
+ if (c < '0' || c > '9') {
+ return -1;
+ }
+ v = v * 10 + (c - '0');
+ }
+ return v < spans.size() ? v : -1;
+ }
+}
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 783b4f020d..6e4b549bf4 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
@@ -18,9 +18,11 @@ package org.apache.tika.parser.vlm;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTimeoutPreemptively;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.io.StringWriter;
+import java.time.Duration;
import javax.xml.transform.OutputKeys;
import javax.xml.transform.sax.SAXTransformerFactory;
import javax.xml.transform.sax.TransformerHandler;
@@ -29,6 +31,8 @@ import javax.xml.transform.stream.StreamResult;
import org.apache.commons.xml.secure.SecureTransformerFactory;
import org.junit.jupiter.api.Test;
+import org.apache.tika.sax.ToMarkdownContentHandler;
+
public class MarkdownToXHTMLEmitterTest {
@Test
@@ -292,6 +296,95 @@ public class MarkdownToXHTMLEmitterTest {
assertContains("<p>This is bold, x2 and a<br/>break.</p>", xml);
}
+ @Test
+ void testInlineMathKeepsBackslashedPunctuation() throws Exception {
+ String xml = emit("the norm $\\|\\cdot\\|$ on $S := \\{u \\in
\\mathcal{H}\\}$ here");
+ assertContains("<span class=\"math inline\">$\\|\\cdot\\|$</span>",
xml);
+ assertContains("<span class=\"math inline\">$S := \\{u \\in
\\mathcal{H}\\}$</span>", xml);
+ }
+
+ @Test
+ void testMathDelimiters() throws Exception {
+ assertContains("<span class=\"math inline\">\\(\\{x\\}\\)</span>",
emit("a \\(\\{x\\}\\) b"));
+ assertContains("<span class=\"math display\">\\[\\|x\\|\\]</span>",
emit("\\[\\|x\\|\\]"));
+ assertContains("<span class=\"math display\">$$a_1 * b_2$$</span>",
emit("$$a_1 * b_2$$"));
+ }
+
+ @Test
+ void testMultiLineDisplayMathIsNotParsedAsBlocks() throws Exception {
+ String xml = emit("$$\nx = a\n- b\n# c\n$$\n\nafter");
+ assertContains("<span class=\"math display\">$$\nx = a\n- b\n#
c\n$$</span>", xml);
+ assertFalse(xml.contains("<li>"), xml);
+ assertFalse(xml.contains("<h1>"), xml);
+ }
+
+ @Test
+ void testMathKeepsEmphasisMarkersLiteral() throws Exception {
+ String xml = emit("$a_1 * b_2 * c_3$ and *em*");
+ assertContains("<span class=\"math inline\">$a_1 * b_2 * c_3$</span>",
xml);
+ assertContains("<i>em</i>", xml);
+ }
+
+ @Test
+ void testDollarsThatAreNotMath() throws Exception {
+ for (String md : new String[] {"costs $5 and $10 today", "a $ b$ c",
"a $b $ c",
+ "pay $x$5", "lone $$ here", "escaped \\$x\\$ here", "split
$a\n\nb$"}) {
+ String xml = emit(md);
+ assertFalse(xml.contains("math"), md + " -> " + xml);
+ }
+ assertContains("costs $5 and $10 today", emit("costs $5 and $10
today"));
+ }
+
+ @Test
+ void testMathInsideCodeIsUntouched() throws Exception {
+ assertContains("<code>$\\|x\\|$</code>", emit("see `$\\|x\\|$` here"));
+ assertContains("<code>$\\{a\\}$\n</code>",
emit("```\n$\\{a\\}$\n```"));
+ // a code span that opens first wins over a later $
+ String xml = emit("`cost $5` and $x$");
+ assertContains("<code>cost $5</code>", xml);
+ assertContains("<span class=\"math inline\">$x$</span>", xml);
+ }
+
+ @Test
+ void testMathWithPipesInGfmTable() throws Exception {
+ String xml = emit("| a | b |\n|---|---|\n| $\\|x\\|$ | 2 |\n");
+ assertContains("<td><span class=\"math
inline\">$\\|x\\|$</span></td><td>2</td>", xml);
+ }
+
+ @Test
+ void testMathInHtmlTableBlock() throws Exception {
+ String xml = emit("<table><tr><td>$\\{a\\} < b$</td></tr></table>");
+ assertContains("<td><span class=\"math inline\">$\\{a\\} <
b$</span></td>", xml);
+ }
+
+ @Test
+ void testPlaceholderCharactersInInputPassThrough() throws Exception {
+ String md = "odd \uE000" + "0\uE001 and $\\{x\\}$";
+ String xml = emit(md);
+ assertContains("odd \uE000" + "0\uE001 and $", xml);
+ assertFalse(xml.contains("<span"), xml);
+ }
+
+ @Test
+ void testManyUnmatchedDollarsIsLinear() {
+ String md = "$a ".repeat(200_000);
+ assertTimeoutPreemptively(Duration.ofSeconds(20), () -> emit(md));
+ }
+
+ @Test
+ void testMathRoundTripsThroughMarkdownWriter() throws Exception {
+ String md = "norm $\\|\\cdot\\|$ with $u_0 \\in \\mathcal{H}$\n\n"
+ + "| a | b |\n|---|---|\n| $\\|x\\|$ | 2 |\n";
+ ToMarkdownContentHandler out = new ToMarkdownContentHandler();
+ out.startDocument();
+ 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);
+ }
+
private static final String XHTML_NS = "http://www.w3.org/1999/xhtml";
/**