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 a85866c036 TIKA-4906: emit raw HTML blocks in VLM markdown output as 
XHTML structure (#3196)
a85866c036 is described below

commit a85866c036f1120fbc8b364f4ccda5283b939aa7
Author: Tim Allison <[email protected]>
AuthorDate: Fri Sep 18 14:16:57 2026 -0400

    TIKA-4906: emit raw HTML blocks in VLM markdown output as XHTML structure 
(#3196)
---
 CHANGES.txt                                        |  3 +
 .../pages/configuration/parsers/vlm-parsers.adoc   |  6 ++
 tika-parsers/tika-parsers-ml/tika-vlm/pom.xml      |  4 +
 .../tika/parser/vlm/MarkdownToXHTMLEmitter.java    | 85 +++++++++++++++++++++-
 .../parser/vlm/MarkdownToXHTMLEmitterTest.java     | 56 ++++++++++++++
 5 files changed, 150 insertions(+), 4 deletions(-)

diff --git a/CHANGES.txt b/CHANGES.txt
index babfb774b0..ecec37d02f 100644
--- a/CHANGES.txt
+++ b/CHANGES.txt
@@ -1,5 +1,8 @@
 Release 4.1.0 - unreleased
 
+   * VLM parsers: a raw HTML block in the model's markdown (jina-ocr-v1 and
+     DeepSeek-OCR write tables that way) is now emitted as XHTML table 
(TIKA-4906).
+
    * The forked-worker CPU cap (-XX:ActiveProcessorCount) is now clamped at its
      floor of 2 (TIKA-4905).
      
diff --git a/docs/modules/ROOT/pages/configuration/parsers/vlm-parsers.adoc 
b/docs/modules/ROOT/pages/configuration/parsers/vlm-parsers.adoc
index 6c98a86a00..567e636207 100644
--- a/docs/modules/ROOT/pages/configuration/parsers/vlm-parsers.adoc
+++ b/docs/modules/ROOT/pages/configuration/parsers/vlm-parsers.adoc
@@ -247,6 +247,12 @@ 
https://github.com/commonmark/commonmark-java[commonmark-java] and emits
 proper XHTML elements (`<h1>`, `<p>`, `<table>`, `<b>`, `<i>`, etc.)
 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>`.
+
 [#_per_request_configuration]
 == Per-request configuration
 
diff --git a/tika-parsers/tika-parsers-ml/tika-vlm/pom.xml 
b/tika-parsers/tika-parsers-ml/tika-vlm/pom.xml
index 2824428b1b..f71543df64 100644
--- a/tika-parsers/tika-parsers-ml/tika-vlm/pom.xml
+++ b/tika-parsers/tika-parsers-ml/tika-vlm/pom.xml
@@ -47,6 +47,10 @@
       <groupId>org.commonmark</groupId>
       <artifactId>commonmark-ext-gfm-tables</artifactId>
     </dependency>
+    <dependency>
+      <groupId>org.jsoup</groupId>
+      <artifactId>jsoup</artifactId>
+    </dependency>
     <dependency>
       <groupId>org.commonmark</groupId>
       <artifactId>commonmark-ext-gfm-strikethrough</artifactId>
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 fee8b36b0b..fe306231f9 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
@@ -18,6 +18,9 @@ package org.apache.tika.parser.vlm;
 
 import java.util.Arrays;
 import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.regex.Pattern;
 
 import org.commonmark.Extension;
 import org.commonmark.ext.gfm.strikethrough.Strikethrough;
@@ -51,6 +54,9 @@ import org.commonmark.node.StrongEmphasis;
 import org.commonmark.node.Text;
 import org.commonmark.node.ThematicBreak;
 import org.commonmark.parser.Parser;
+import org.jsoup.Jsoup;
+import org.jsoup.nodes.Element;
+import org.jsoup.nodes.TextNode;
 import org.xml.sax.ContentHandler;
 import org.xml.sax.SAXException;
 import org.xml.sax.helpers.AttributesImpl;
@@ -71,6 +77,11 @@ import org.xml.sax.helpers.AttributesImpl;
  *       {@code th}, {@code td})</li>
  *   <li>Thematic breaks ({@code hr})</li>
  *   <li>Hard / soft line breaks ({@code br})</li>
+ *   <li>Raw HTML blocks, which OCR models such as jina-ocr-v1 and 
DeepSeek-OCR use for
+ *       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>
  * </ul>
  *
  * @since Apache Tika 4.0
@@ -90,6 +101,23 @@ class MarkdownToXHTMLEmitter {
 
     private static final AttributesImpl EMPTY_ATTRS = new AttributesImpl();
 
+    /** HTML elements re-emitted by name from a raw HTML block; the rest 
contribute text only. */
+    private static final Set<String> HTML_PASSTHROUGH = Set.of(
+            "table", "thead", "tbody", "tfoot", "tr", "th", "td",
+            "p", "br", "ul", "ol", "li", "blockquote", "pre", "code",
+            "h1", "h2", "h3", "h4", "h5", "h6", "b", "i", "s", "sup", "sub");
+
+    private static final Map<String, String> HTML_RENAMES = Map.of(
+            "strong", "b", "em", "i", "del", "s", "strike", "s");
+
+    private static final Set<String> HTML_DROPPED = Set.of("script", "style", 
"head");
+
+    /** Table structure elements, where whitespace-only text is formatting, 
not content. */
+    private static final Set<String> HTML_STRUCTURAL = Set.of(
+            "table", "thead", "tbody", "tfoot", "tr", "ul", "ol");
+
+    private static final Pattern INLINE_BR = Pattern.compile("(?i)<br\\s*/?>");
+
     /**
      * Parses the given markdown text and emits SAX events to the handler.
      * <p>
@@ -214,8 +242,54 @@ class MarkdownToXHTMLEmitter {
 
         @Override
         public void visit(HtmlBlock htmlBlock) {
-            // Emit raw HTML content as plain text — we don't parse nested HTML
-            characters(htmlBlock.getLiteral());
+            Element body = 
Jsoup.parseBodyFragment(htmlBlock.getLiteral()).body();
+            for (org.jsoup.nodes.Node child : body.childNodes()) {
+                emitHtml(child, false);
+            }
+        }
+
+        private void emitHtml(org.jsoup.nodes.Node node, boolean inStructure) {
+            if (node instanceof TextNode) {
+                String text = ((TextNode) node).getWholeText();
+                if (!(inStructure && text.isBlank())) {
+                    characters(text);
+                }
+                return;
+            }
+            if (!(node instanceof Element)) {
+                return;
+            }
+            String name = ((Element) node).normalName();
+            if (HTML_DROPPED.contains(name)) {
+                return;
+            }
+            String tag = HTML_RENAMES.getOrDefault(name, name);
+            boolean emit = HTML_PASSTHROUGH.contains(tag);
+            if (emit) {
+                startElement(tag, spanAttributes((Element) node));
+            }
+            boolean structural = HTML_STRUCTURAL.contains(tag);
+            for (org.jsoup.nodes.Node child : node.childNodes()) {
+                emitHtml(child, structural);
+            }
+            if (emit) {
+                endElement(tag);
+            }
+        }
+
+        /** colspan/rowspan are structure, not presentation: the only 
attributes kept. */
+        private AttributesImpl spanAttributes(Element element) {
+            AttributesImpl attrs = EMPTY_ATTRS;
+            for (String span : new String[] {"colspan", "rowspan"}) {
+                String v = element.attr(span).trim();
+                if (v.matches("[1-9][0-9]*") && !v.equals("1")) {
+                    if (attrs == EMPTY_ATTRS) {
+                        attrs = new AttributesImpl();
+                    }
+                    attrs.addAttribute("", span, span, "CDATA", v);
+                }
+            }
+            return attrs;
         }
 
         // --- inline nodes ---
@@ -290,8 +364,11 @@ class MarkdownToXHTMLEmitter {
 
         @Override
         public void visit(HtmlInline htmlInline) {
-            // Emit inline HTML as plain text
-            characters(htmlInline.getLiteral());
+            // 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()) {
+                emptyElement("br");
+            }
         }
 
         // --- 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 71ed9548e7..afc03b2e71 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
@@ -17,6 +17,7 @@
 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.assertTrue;
 
 import java.io.StringWriter;
@@ -226,6 +227,61 @@ public class MarkdownToXHTMLEmitterTest {
                 + "<root xmlns=\"http://www.w3.org/1999/xhtml\"/>", xml);
     }
 
+    @Test
+    void testHtmlTableBlock() throws Exception {
+        // verbatim jina-ocr-v1 output for a three-column table followed by a 
sentence
+        String md = "<table>\n<thead>\n  <tr class=\"table-header\">\n    
<th>Region</th>\n"
+                + "    <th>Q1</th>\n    <th>Q2</th>\n  
</tr>\n</thead>\n<tbody>\n"
+                + "  <tr class=\"row-odd\">\n    <th>North</th>\n    
<th>120</th>\n"
+                + "    <th>135</th>\n  </tr>\n</tbody>\n</table>\n\n"
+                + "Totals are in thousands of units.\n";
+        String xml = emit(md);
+        
assertContains("<table><thead><tr><th>Region</th><th>Q1</th><th>Q2</th></tr></thead>"
+                + 
"<tbody><tr><th>North</th><th>120</th><th>135</th></tr></tbody></table>", xml);
+        assertContains("<p>Totals are in thousands of units.</p>", xml);
+        assertFalse(xml.contains("&lt;"), "raw tags leaked as text:\n" + xml);
+        assertFalse(xml.contains("class="), "attributes should be dropped:\n" 
+ xml);
+    }
+
+    @Test
+    void testHtmlBlockUnknownElementsKeepTextDropScripts() throws Exception {
+        String md = "<div class=\"x\"><span>hello</span> 
<strong>there</strong>"
+                + "<script>alert(1)</script><style>p{}</style> a &amp; 
b</div>\n";
+        String xml = emit(md);
+        assertContains("hello <b>there</b> a &amp; b", xml);
+        assertFalse(xml.contains("alert"), xml);
+        assertFalse(xml.contains("p{}"), xml);
+        assertFalse(xml.contains("<div"), xml);
+        assertFalse(xml.contains("<span"), xml);
+    }
+
+    @Test
+    void testHtmlTableKeepsColspanRowspan() throws Exception {
+        String md = "<table><tr><th colspan=\"2\" 
class=\"hdr\">Sales</th></tr>"
+                + "<tr><td rowspan=\"1\" colspan=\"0\" style=\"x\">a</td>"
+                + "<td rowspan=\" 3 \">b</td></tr></table>\n";
+        String xml = emit(md);
+        assertContains("<th colspan=\"2\">Sales</th>", xml);
+        assertContains("<td>a</td>", xml);
+        assertContains("<td rowspan=\"3\">b</td>", xml);
+        assertFalse(xml.contains("class="), xml);
+        assertFalse(xml.contains("style="), xml);
+    }
+
+    @Test
+    void testHtmlBlockUnclosedTable() throws Exception {
+        String md = "<table><tr><td>a<td>b\n";
+        String xml = emit(md);
+        
assertContains("<table><tbody><tr><td>a</td><td>b</td></tr></tbody></table>", 
xml);
+    }
+
+    @Test
+    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);
+    }
+
     private static final String XHTML_NS = "http://www.w3.org/1999/xhtml";;
 
     /**

Reply via email to