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("<"), "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 &
b</div>\n";
+ String xml = emit(md);
+ assertContains("hello <b>there</b> a & 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";
/**