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 fcdf187e3d TIKA-5061: let commonmark size the code fence, so a three 
backtick li… (#3568)
fcdf187e3d is described below

commit fcdf187e3d6e5177ecb98c0ab624b6a7b7190fd9
Author: Tim Allison <[email protected]>
AuthorDate: Sat Nov 7 17:39:27 2026 -0500

    TIKA-5061: let commonmark size the code fence, so a three backtick li… 
(#3568)
    
    * TIKA-5061: let commonmark size the code fence, so a three backtick line 
in <pre> cannot close the block
    
    * TIKA-5061: escape a leading + in markdown text; flatten nesting past 64 
levels
---
 CHANGES.txt                                        |   8 ++
 .../apache/tika/sax/FastMarkdownTextRenderer.java  |  14 ++-
 .../apache/tika/sax/ToMarkdownContentHandler.java  |  60 +++++++--
 .../tika/sax/FastMarkdownTextRendererTest.java     |  13 +-
 .../tika/sax/ToMarkdownContentHandlerTest.java     | 139 +++++++++++++++++++++
 5 files changed, 222 insertions(+), 12 deletions(-)

diff --git a/CHANGES.txt b/CHANGES.txt
index def32a6c9c..716c93528a 100644
--- a/CHANGES.txt
+++ b/CHANGES.txt
@@ -104,6 +104,14 @@ Release 4.3.0 - ???
 
   EXTRACTION AND FORMATS
 
+   * Markdown output keeps document code inside its code spans and blocks: a 
line of
+     backticks inside <pre> no longer closes the fenced code block, and a line 
break inside
+     inline code, alt text or a link destination, or a lone CR inside <pre>, 
no longer starts
+     a new markdown line, so none of them can turn the text after it into raw 
HTML. A
+     paragraph starting with "+ " stays a paragraph instead of becoming a 
list, and
+     elements nested more than 64 levels deep are flattened, so deep nesting 
can no longer
+     overflow the stack or make the output grow with the square of the depth 
(TIKA-5061).
+
    * HTML main-content extraction: tika-readability is a jsoup-only Java port 
of Mozilla
      Readability, and "readability-jsoup-filter" in the parse context runs it 
in JSoupParser
      (off by default; HTML documents only, never message bodies or other HTML 
inside a
diff --git 
a/tika-core/src/main/java/org/apache/tika/sax/FastMarkdownTextRenderer.java 
b/tika-core/src/main/java/org/apache/tika/sax/FastMarkdownTextRenderer.java
index e821ff35c9..f0d5f0545b 100644
--- a/tika-core/src/main/java/org/apache/tika/sax/FastMarkdownTextRenderer.java
+++ b/tika-core/src/main/java/org/apache/tika/sax/FastMarkdownTextRenderer.java
@@ -42,9 +42,11 @@ import org.commonmark.text.AsciiMatcher;
  * The escaping semantics replicate {@code 
CoreMarkdownNodeRenderer#visit(Text)} for
  * the commonmark version pinned in tika-parent exactly: the line-start 
disambiguation
  * cases, the heading escape variant, the {@code !}-before-link case, and the
- * {@code \n} numeric reference. {@code FastMarkdownTextRendererTest} renders 
ASTs
- * through this renderer and the stock one and requires byte-identical output; 
a
- * commonmark upgrade that changes escaping fails there and must be re-synced 
here.
+ * {@code \n} numeric reference. The one deliberate difference: a {@code +} at 
line
+ * start is escaped, since it is a bullet marker the stock renderer misses.
+ * {@code FastMarkdownTextRendererTest} renders ASTs through this renderer and 
the
+ * stock one and requires byte-identical output otherwise; a commonmark 
upgrade that
+ * changes escaping fails there and must be re-synced here.
  * <p>
  * Text inside a table cell renders through the stock per-char path: the 
tables extension
  * pushes a raw-escape for {@code |} onto the writer there, and hand-emitted 
escapes would
@@ -115,6 +117,12 @@ class FastMarkdownTextRenderer implements NodeRenderer {
                     literal = literal.substring(1);
                     break;
                 }
+                case '+': {
+                    // a bullet marker too; commonmark 0.31.0 leaves it 
unescaped
+                    writer.raw("\\+");
+                    literal = literal.substring(1);
+                    break;
+                }
                 case '=': {
                     if (text.getPrevious() != null) {
                         writer.raw("\\=");
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 00c7b95d1a..804042012e 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
@@ -25,6 +25,7 @@ import java.io.Writer;
 import java.util.ArrayDeque;
 import java.util.Arrays;
 import java.util.Deque;
+import java.util.Iterator;
 import java.util.List;
 import java.util.Locale;
 
@@ -133,6 +134,13 @@ public class ToMarkdownContentHandler extends 
DefaultHandler {
     // <script>/<style> content is dropped.
     private int suppressDepth;
 
+    /**
+     * Deepest open-node stack that still nests: commonmark renders 
recursively (a stack
+     * overflow at a few thousand levels) and prefixes every line with its 
containers, so
+     * output would grow with the square of the depth. Past it, content joins 
the current node.
+     */
+    static final int MAX_NESTING = 64;
+
     // True once endDocument has rendered the full document to the writer.
     private boolean finished;
 
@@ -180,12 +188,22 @@ public class ToMarkdownContentHandler extends 
DefaultHandler {
                 openBlock(new Paragraph());
                 break;
             case "blockquote":
-                openBlock(new BlockQuote());
+                popToBlockHost();
+                if (!flattenAtCap()) {
+                    openBlock(new BlockQuote());
+                }
                 break;
             case "ul":
-                openBlock(newBulletList());
+                popToBlockHost();
+                if (!flattenAtCap()) {
+                    openBlock(newBulletList());
+                }
                 break;
             case "ol":
+                popToBlockHost();
+                if (flattenAtCap()) {
+                    break;
+                }
                 OrderedList ol = new OrderedList();
                 ol.setMarkerStartNumber(1);
                 ol.setMarkerDelimiter(".");
@@ -198,8 +216,8 @@ public class ToMarkdownContentHandler extends 
DefaultHandler {
             case "pre":
                 popToBlockHost();
                 codeBlock = new FencedCodeBlock();
+                // no fence length: commonmark makes the fence longer than any 
backtick run inside
                 codeBlock.setFenceCharacter("`");
-                codeBlock.setOpeningFenceLength(3);
                 codeBlock.setLiteral("");
                 stack.peek().appendChild(codeBlock);
                 codeBlockText = new StringBuilder();
@@ -345,8 +363,9 @@ public class ToMarkdownContentHandler extends 
DefaultHandler {
                 closeImplicitParagraph();
                 Node popped = stack.size() > 1 ? stack.peek() : null;
                 pop();
-                if (popped instanceof Emphasis || popped instanceof 
StrongEmphasis
-                        || popped instanceof Strikethrough) {
+                // a flattened duplicate: the node is still open below
+                if (popped != stack.peek() && (popped instanceof Emphasis
+                        || popped instanceof StrongEmphasis || popped 
instanceof Strikethrough)) {
                     trimDelimited(popped);
                 }
                 break;
@@ -609,6 +628,10 @@ public class ToMarkdownContentHandler extends 
DefaultHandler {
      * implicit bullet list so the model stays renderable.
      */
     private void openListItem() {
+        // an item of an open list always opens, or its text would be dropped
+        if (!(stack.peek() instanceof ListBlock) && flattenAtCap()) {
+            return;
+        }
         if (!(stack.peek() instanceof ListBlock)) {
             popToBlockHost();
             BulletList implicit = newBulletList();
@@ -622,10 +645,31 @@ public class ToMarkdownContentHandler extends 
DefaultHandler {
 
     private void openInline(Node node) {
         ensureInlineContainer();
+        if (flattenAtCap()) {
+            return;
+        }
         stack.peek().appendChild(node);
         stack.push(node);
     }
 
+    /**
+     * At {@link #MAX_NESTING}, pushes the current node again so the element's 
content joins
+     * it and the element's end pops the duplicate.
+     */
+    private boolean flattenAtCap() {
+        if (stack.size() < MAX_NESTING) {
+            return false;
+        }
+        stack.push(stack.peek());
+        return true;
+    }
+
+    private boolean topIsDuplicate() {
+        Iterator<Node> it = stack.iterator();
+        Node top = it.next();
+        return it.hasNext() && it.next() == top;
+    }
+
     /**
      * Markdown delimiters cannot sit next to whitespace inside them ({@code 
** x**} is
      * literal), so moves edge whitespace outside and drops the node if 
nothing is left.
@@ -678,7 +722,7 @@ public class ToMarkdownContentHandler extends 
DefaultHandler {
     private void pop() {
         if (stack.size() > 1) {
             Node popped = stack.pop();
-            if (popped == implicitParagraph) {
+            if (popped == implicitParagraph && stack.peek() != popped) {
                 implicitParagraph = null;
             }
         }
@@ -688,7 +732,7 @@ public class ToMarkdownContentHandler extends 
DefaultHandler {
     private void popToBlockHost() {
         while (stack.size() > 1 && !canHostBlocks(stack.peek())) {
             Node popped = stack.pop();
-            if (popped == implicitParagraph) {
+            if (popped == implicitParagraph && stack.peek() != popped) {
                 implicitParagraph = null;
             }
         }
@@ -709,7 +753,7 @@ public class ToMarkdownContentHandler extends 
DefaultHandler {
     }
 
     private void closeImplicitParagraph() {
-        if (implicitParagraph != null && stack.peek() == implicitParagraph) {
+        if (implicitParagraph != null && stack.peek() == implicitParagraph && 
!topIsDuplicate()) {
             stack.pop();
             implicitParagraph = null;
         }
diff --git 
a/tika-core/src/test/java/org/apache/tika/sax/FastMarkdownTextRendererTest.java 
b/tika-core/src/test/java/org/apache/tika/sax/FastMarkdownTextRendererTest.java
index 9122e84269..39412d3063 100644
--- 
a/tika-core/src/test/java/org/apache/tika/sax/FastMarkdownTextRendererTest.java
+++ 
b/tika-core/src/test/java/org/apache/tika/sax/FastMarkdownTextRendererTest.java
@@ -82,7 +82,7 @@ public class FastMarkdownTextRendererTest {
     public void testLineStartEscapes() {
         // Literals whose first char would parse as block structure at line 
start
         for (String literal : new String[]{
-                "- not a list", "-not a list", "-", "+ plus", "* star",
+                "- not a list", "-not a list", "-", "* star",
                 "# not a heading", "#hash", "#",
                 "= not setext", "=",
                 "1. not ordered", "12. also not", "123456789. limit", "1) 
paren",
@@ -93,6 +93,17 @@ public class FastMarkdownTextRendererTest {
         }
     }
 
+    @Test
+    public void testPlusAtLineStartIsEscaped() {
+        // deliberate divergence: stock commonmark lets "+ x" become a bullet 
list
+        for (String literal : new String[]{"+ plus", "+", "+1"}) {
+            String md = FAST.render(paragraphs(literal));
+            assertTrue(md.startsWith("\\+"), md);
+            assertTrue(PARSER.parse(md).getFirstChild() instanceof Paragraph, 
md);
+        }
+        assertSameRendering(paragraphs("mid ", "+ plus"));
+    }
+
     @Test
     public void testSecondTextNodeNotAtLineStart() {
         // The line-start branches must not fire mid-line
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 6d43be4656..9cdacff20e 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
@@ -440,6 +440,145 @@ public class ToMarkdownContentHandlerTest {
         assertTrue(result.contains("\n```"));
     }
 
+    @Test
+    public void testFenceInCodeBlockCannotCloseIt() throws Exception {
+        for (String container : new String[]{"", "li", "blockquote"}) {
+            for (String fence : new String[]{"```", "````", "   ```", 
"```js"}) {
+                ToMarkdownContentHandler handler = new 
ToMarkdownContentHandler();
+                handler.startDocument();
+                if (container.equals("li")) {
+                    startElement(handler, "ul");
+                }
+                if (!container.isEmpty()) {
+                    startElement(handler, container);
+                }
+                startElement(handler, "pre");
+                chars(handler, "a\n" + fence + "\n<img src=x 
onerror=alert(1)>\n");
+                endElement(handler, "pre");
+                if (!container.isEmpty()) {
+                    endElement(handler, container);
+                }
+                if (container.equals("li")) {
+                    endElement(handler, "ul");
+                }
+                handler.endDocument();
+
+                String md = handler.toString();
+                String html = renderHtml(md);
+                assertFalse(html.contains("<img"), md);
+                assertContains("&lt;img src=x onerror=alert(1)&gt;", html);
+            }
+        }
+    }
+
+    @Test
+    public void testPlusAtLineStartIsNotAList() throws Exception {
+        for (String container : new String[]{"p", "li", "blockquote"}) {
+            ToMarkdownContentHandler handler = new ToMarkdownContentHandler();
+            handler.startDocument();
+            if (container.equals("li")) {
+                startElement(handler, "ul");
+            }
+            startElement(handler, container);
+            chars(handler, "+ not a list");
+            endElement(handler, container);
+            if (container.equals("li")) {
+                endElement(handler, "ul");
+            }
+            handler.endDocument();
+
+            String md = handler.toString();
+            String html = renderHtml(md);
+            assertContains("+ not a list", html);
+            assertFalse(html.contains("<li>not a list"), md);
+        }
+    }
+
+    @ParameterizedTest
+    @ValueSource(strings = {"blockquote", "ul", "b", "s", "a"})
+    public void testDeepNestingIsCappedWithoutLosingText(String element) 
throws Exception {
+        int depth = 20_000;
+        ToMarkdownContentHandler handler = new ToMarkdownContentHandler();
+        handler.startDocument();
+        boolean inline = !element.equals("blockquote") && 
!element.equals("ul");
+        if (inline) {
+            startElement(handler, "p");
+        }
+        for (int i = 0; i < depth; i++) {
+            startElement(handler, element);
+            if (element.equals("ul")) {
+                startElement(handler, "li");
+            }
+            chars(handler, "w" + i + " ");
+        }
+        for (int i = 0; i < depth; i++) {
+            if (element.equals("ul")) {
+                endElement(handler, "li");
+            }
+            endElement(handler, element);
+        }
+        if (inline) {
+            endElement(handler, "p");
+        }
+        chars(handler, "after");
+        handler.endDocument();
+
+        String md = handler.toString();
+        // the cap keeps each line's container prefix short: output stays 
linear in the input
+        assertTrue(md.length() < depth * 400, "markdown length " + 
md.length());
+        String text = renderHtml(md);
+        for (int i : new int[]{0, 63, 64, 19_999}) {
+            assertContains("w" + i, text);
+        }
+        assertContains("after", text);
+    }
+
+    @Test
+    public void testEmptyFlattenedInlineKeepsLaterText() throws Exception {
+        ToMarkdownContentHandler handler = new ToMarkdownContentHandler();
+        handler.startDocument();
+        startElement(handler, "p");
+        int depth = ToMarkdownContentHandler.MAX_NESTING + 6;
+        for (int i = 0; i < depth; i++) {
+            startElement(handler, "b");
+        }
+        endElement(handler, "b");
+        chars(handler, "kept");
+        for (int i = 1; i < depth; i++) {
+            endElement(handler, "b");
+        }
+        endElement(handler, "p");
+        handler.endDocument();
+
+        assertContains("kept", handler.toString());
+    }
+
+    @Test
+    public void testFlattenedInlineDoesNotSplitParagraph() throws Exception {
+        // sweeps the depths at which the cap lands right on the implicit 
paragraph
+        for (int depth = ToMarkdownContentHandler.MAX_NESTING - 6;
+                depth <= ToMarkdownContentHandler.MAX_NESTING + 6; depth++) {
+            ToMarkdownContentHandler handler = new ToMarkdownContentHandler();
+            handler.startDocument();
+            for (int i = 0; i < depth; i++) {
+                startElement(handler, "blockquote");
+            }
+            chars(handler, "x ");
+            startElement(handler, "b");
+            chars(handler, "z");
+            endElement(handler, "b");
+            chars(handler, " y");
+            for (int i = 0; i < depth; i++) {
+                endElement(handler, "blockquote");
+            }
+            handler.endDocument();
+
+            String html = renderHtml(handler.toString());
+            assertTrue(html.matches("(?s).*<p>x (<strong>z</strong>|z) 
y</p>.*"),
+                    "depth " + depth + ": " + html);
+        }
+    }
+
     @Test
     public void testInlineCode() throws Exception {
         ToMarkdownContentHandler handler = new ToMarkdownContentHandler();

Reply via email to