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 198065a922 keep <sup>/<sub> in VLM output and write them as inline
HTML in Markdown; document VLM math and inline HTML (#3460)
198065a922 is described below
commit 198065a922ed9e5012b6ce9e65541d144ef6b9e6
Author: Tim Allison <[email protected]>
AuthorDate: Fri Oct 30 17:05:24 2026 -0400
keep <sup>/<sub> in VLM output and write them as inline HTML in Markdown;
document VLM math and inline HTML (#3460)
handling
Co-Authored-By: Claude Opus 5.5 <[email protected]>
---
CHANGES.txt | 5 ++
.../pages/configuration/parsers/vlm-parsers.adoc | 15 ++++--
.../apache/tika/sax/ToMarkdownContentHandler.java | 37 ++++++++++++++
.../tika/sax/ToMarkdownContentHandlerTest.java | 44 ++++++++++++++++
.../tika/parser/vlm/MarkdownToXHTMLEmitter.java | 59 +++++++++++++++++++++-
.../parser/vlm/MarkdownToXHTMLEmitterTest.java | 33 +++++++++++-
6 files changed, 187 insertions(+), 6 deletions(-)
diff --git a/CHANGES.txt b/CHANGES.txt
index 9e4065c753..fee32d6a5b 100644
--- a/CHANGES.txt
+++ b/CHANGES.txt
@@ -56,6 +56,11 @@ Release 4.3.0 - ???
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).
+ * VLM text recognizers keep superscripts and subscripts: inline <sup>/<sub>
in model
+ output (x<sup>2</sup>, H<sub>2</sub>O, footnote markers) reach the XHTML
as sup/sub
+ instead of plain text ("x2"), and the Markdown writer outputs them as
inline HTML
+ (TIKA-XXXX).
+
OTHER CHANGES
* A runtime fetcher or emitter save or delete reaches running pipes workers
at
diff --git a/docs/modules/ROOT/pages/configuration/parsers/vlm-parsers.adoc
b/docs/modules/ROOT/pages/configuration/parsers/vlm-parsers.adoc
index 2489cd150a..2b79d7c3ae 100644
--- a/docs/modules/ROOT/pages/configuration/parsers/vlm-parsers.adoc
+++ b/docs/modules/ROOT/pages/configuration/parsers/vlm-parsers.adoc
@@ -310,9 +310,18 @@ instead of dumping raw text. GFM tables and strikethrough
are supported.
Document-OCR models such as jina-ocr-v1 and DeepSeek-OCR write tables as raw
HTML inside the
markdown, and a prompt does not change that. An HTML block is parsed leniently
and re-emitted as
-the same structural elements (`table`, `tr`, `th`, `td`, `p`, lists, headings)
with attributes
-dropped except `colspan` and `rowspan`; other elements contribute their text,
and `script`/`style` contribute nothing. Inline
-HTML tags are dropped and their text kept, except `<br>`.
+the same elements (`table`, `tr`, `th`, `td`, `p`, lists, headings,
`blockquote`, `pre`, `code`,
+`b`, `i`, `s`, `sup`, `sub`) with attributes dropped except `colspan` and
`rowspan`; other
+elements contribute their text, and `script`/`style` contribute nothing.
Outside an HTML block,
+inline HTML tags are dropped and their text kept, except `<br>` and matched
`<sup>`/`<sub>` pairs.
+
+Superscripts and subscripts are `sup`/`sub` in XHTML and inline HTML in
markdown output
+(`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
+left alone. Math containing a backtick or a blank line is not recognized.
[#_per_request_configuration]
== Per-request configuration
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 beb8a42e94..010b3de38c 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
@@ -109,6 +109,8 @@ public class ToMarkdownContentHandler extends
DefaultHandler {
// Buffer for <span class="math ..."> TeX, written verbatim; depth counts
nested spans.
private StringBuilder mathText;
private int mathSpanDepth;
+ /** Open {@code <sup>}/{@code <sub>} tags written as raw inline HTML. */
+ private final Deque<HtmlInline> rawInlineOpen = new ArrayDeque<>();
// Buffer for <pre> fenced code block literal.
private FencedCodeBlock codeBlock;
@@ -268,6 +270,15 @@ public class ToMarkdownContentHandler extends
DefaultHandler {
mathSpanDepth = 1;
}
break;
+ case "sup":
+ case "sub":
+ if (codeBlockText == null && inlineCodeText == null &&
mathText == null) {
+ ensureInlineContainer();
+ HtmlInline open = rawInline("<" + name + ">");
+ stack.peek().appendChild(open);
+ rawInlineOpen.push(open);
+ }
+ break;
case "img":
ensureInlineContainer();
Image img = new Image(value(atts, "src"), null);
@@ -351,6 +362,12 @@ public class ToMarkdownContentHandler extends
DefaultHandler {
mathText = null;
}
break;
+ case "sup":
+ case "sub":
+ if (codeBlockText == null && inlineCodeText == null &&
mathText == null) {
+ closeRawInline(name);
+ }
+ break;
case "table":
endTable();
break;
@@ -690,6 +707,26 @@ public class ToMarkdownContentHandler extends
DefaultHandler {
stack.peek().appendChild(raw);
}
+ /** Writes the close tag next to its open one; if a block intervened,
drops the open. */
+ private void closeRawInline(String name) {
+ HtmlInline open = rawInlineOpen.peek();
+ if (open == null || !open.getLiteral().equals("<" + name + ">")) {
+ return;
+ }
+ rawInlineOpen.pop();
+ if (open.getParent() == stack.peek()) {
+ stack.peek().appendChild(rawInline("</" + name + ">"));
+ } else {
+ open.unlink();
+ }
+ }
+
+ private static HtmlInline rawInline(String literal) {
+ HtmlInline raw = new HtmlInline();
+ raw.setLiteral(literal);
+ return raw;
+ }
+
private static boolean isMathClass(String cls) {
if (cls == null) {
return false;
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 610cec5eca..717456e7fb 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
@@ -545,6 +545,50 @@ public class ToMarkdownContentHandlerTest {
assertContains("u\\_0\\*", handler.toString());
}
+ @Test
+ public void testSupSubWrittenAsInlineHtml() throws Exception {
+ ToMarkdownContentHandler handler = new ToMarkdownContentHandler();
+ handler.startDocument();
+ startElement(handler, "p");
+ chars(handler, "x");
+ startElement(handler, "sup");
+ chars(handler, "2*");
+ endElement(handler, "sup");
+ chars(handler, " H");
+ startElement(handler, "sub");
+ startElement(handler, "b");
+ chars(handler, "2");
+ endElement(handler, "b");
+ endElement(handler, "sub");
+ chars(handler, "O ");
+ startElement(handler, "code");
+ chars(handler, "a");
+ startElement(handler, "sup");
+ chars(handler, "b");
+ endElement(handler, "sup");
+ endElement(handler, "code");
+ endElement(handler, "p");
+ handler.endDocument();
+ assertContains("x<sup>2\\*</sup> H<sub>**2**</sub>O `ab`",
handler.toString());
+ }
+
+ @Test
+ public void testSupAcrossBlockDropsTags() throws Exception {
+ ToMarkdownContentHandler handler = new ToMarkdownContentHandler();
+ handler.startDocument();
+ startElement(handler, "sup");
+ chars(handler, "a");
+ startElement(handler, "p");
+ chars(handler, "b");
+ endElement(handler, "p");
+ endElement(handler, "sup");
+ handler.endDocument();
+ String md = handler.toString();
+ assertFalse(md.contains("sup>"), md);
+ assertContains("a", md);
+ assertContains("b", md);
+ }
+
@Test
public void testTextAfterMathSpanKeepsLeadingSpace() 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 b2a047d44d..f30b327e23 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
@@ -16,10 +16,16 @@
*/
package org.apache.tika.parser.vlm;
+import java.util.ArrayDeque;
import java.util.Arrays;
+import java.util.Collections;
+import java.util.Deque;
+import java.util.IdentityHashMap;
import java.util.List;
+import java.util.Locale;
import java.util.Map;
import java.util.Set;
+import java.util.regex.Matcher;
import java.util.regex.Pattern;
import org.commonmark.Extension;
@@ -81,7 +87,8 @@ import org.xml.sax.helpers.AttributesImpl;
* tables: parsed leniently and re-emitted as the same structural subset
with
* 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>
+ * dropped (their text is kept), except {@code <br>} and balanced
+ * {@code <sup>}/{@code <sub>} pairs.</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>
@@ -121,6 +128,8 @@ class MarkdownToXHTMLEmitter {
private static final Pattern INLINE_BR = Pattern.compile("(?i)<br\\s*/?>");
+ private static final Pattern INLINE_SUP_SUB =
Pattern.compile("(?i)<(/?)(sup|sub)\\s*>");
+
/**
* Parses the given markdown text and emits SAX events to the handler.
* <p>
@@ -152,6 +161,8 @@ class MarkdownToXHTMLEmitter {
private final ContentHandler handler;
private final MathSpans math;
+ /** Close tags of the {@code sup}/{@code sub} pairs opened so far. */
+ private final Set<Node> pairedCloses = Collections.newSetFromMap(new
IdentityHashMap<>());
SAXException saxException;
SAXVisitor(ContentHandler handler, MathSpans math) {
@@ -379,9 +390,53 @@ class MarkdownToXHTMLEmitter {
public void visit(HtmlInline htmlInline) {
// Open and close tags arrive as separate nodes with the text
between them as
// siblings, so the tag itself is dropped and the text survives.
- if (INLINE_BR.matcher(htmlInline.getLiteral()).matches()) {
+ String literal = htmlInline.getLiteral();
+ if (INLINE_BR.matcher(literal).matches()) {
emptyElement("br");
+ return;
+ }
+ Matcher m = INLINE_SUP_SUB.matcher(literal);
+ if (!m.matches()) {
+ return;
+ }
+ String tag = m.group(2).toLowerCase(Locale.ROOT);
+ if (m.group(1).isEmpty()) {
+ Node close = findClose(htmlInline, tag);
+ if (close != null) {
+ pairedCloses.add(close);
+ startElement(tag);
+ }
+ } else if (pairedCloses.remove(htmlInline)) {
+ endElement(tag);
+ }
+ }
+
+ /**
+ * The sibling close tag that balances {@code open}, or null when
there is none or
+ * the tags between cross it; an unbalanced tag stays text-only.
+ */
+ private static Node findClose(HtmlInline open, String tag) {
+ Deque<String> between = new ArrayDeque<>();
+ for (Node n = open.getNext(); n != null; n = n.getNext()) {
+ if (!(n instanceof HtmlInline)) {
+ continue;
+ }
+ Matcher m = INLINE_SUP_SUB.matcher(((HtmlInline)
n).getLiteral());
+ if (!m.matches()) {
+ continue;
+ }
+ String name = m.group(2).toLowerCase(Locale.ROOT);
+ if (m.group(1).isEmpty()) {
+ between.push(name);
+ } else if (between.isEmpty()) {
+ return name.equals(tag) ? n : null;
+ } else if (between.peek().equals(name)) {
+ between.pop();
+ } else {
+ return null;
+ }
}
+ return null;
}
// --- GFM extensions ---
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 6e4b549bf4..06dd59897b 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
@@ -293,7 +293,38 @@ public class MarkdownToXHTMLEmitterTest {
void testInlineHtmlTagsDroppedTextKept() throws Exception {
String md = "This is <b>bold</b>, x<sup>2</sup> and a<br>break.";
String xml = emit(md);
- assertContains("<p>This is bold, x2 and a<br/>break.</p>", xml);
+ assertContains("<p>This is bold, x<sup>2</sup> and a<br/>break.</p>",
xml);
+ }
+
+ @Test
+ void testInlineSupSub() throws Exception {
+ String xml = emit("E = mc<SUP>2</SUP>, H<sub>2</sub>O,
x<sup>a<sub>i</sub></sup> and word<sup>3</sup>");
+ assertContains("<p>E = mc<sup>2</sup>, H<sub>2</sub>O,
x<sup>a<sub>i</sub></sup>"
+ + " and word<sup>3</sup></p>", xml);
+ }
+
+ @Test
+ void testUnbalancedSupSubKeepTextOnly() throws Exception {
+ String xml = emit("a<sup>1 b</sup>2</sup> c<sub>3 d<sup>4</sub>5</sup>
e<sup>6\n\nf</sup>");
+ assertContains("a<sup>1 b</sup>2 c3 d45 e6", xml);
+ // *...* closes inside the sup: the pair is not siblings, so text only
+ xml = emit("*a<sup>b*</sup> c");
+ assertContains("<i>ab</i> c", xml);
+ }
+
+ @Test
+ void testSupSubRoundTripsThroughMarkdownWriter() throws Exception {
+ String md = "x<sup>2</sup> and H<sub>2</sub>O\n\n"
+ + "| a | b |\n|---|---|\n| m<sup>3</sup> | 2 |\n\n"
+ + "<table><tr><td>cm<sup>2</sup></td></tr></table>\n";
+ ToMarkdownContentHandler out = new ToMarkdownContentHandler();
+ out.startDocument();
+ MarkdownToXHTMLEmitter.emit(md, out);
+ out.endDocument();
+ String written = out.toString();
+ assertContains("x<sup>2</sup> and H<sub>2</sub>O", written);
+ assertContains("|m<sup>3</sup>|2|", written);
+ assertContains("cm<sup>2</sup>", written);
}
@Test