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

Reply via email to