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 fe1ccec17c TIKA-4894: walk PDF bookmark outlines without recursion 
(#3167)
fe1ccec17c is described below

commit fe1ccec17c54353d945bb77864b14f70d68d98dc
Author: Tim Allison <[email protected]>
AuthorDate: Mon Sep 14 16:10:17 2026 -0400

    TIKA-4894: walk PDF bookmark outlines without recursion (#3167)
---
 CHANGES.txt                                        |  3 +
 .../apache/tika/parser/pdf/AbstractPDF2XHTML.java  | 71 +++++++++++++++-------
 .../org/apache/tika/parser/pdf/PDFParserTest.java  | 65 ++++++++++++++++++++
 3 files changed, 116 insertions(+), 23 deletions(-)

diff --git a/CHANGES.txt b/CHANGES.txt
index 5c269968e1..ba9ac60f3a 100644
--- a/CHANGES.txt
+++ b/CHANGES.txt
@@ -1,5 +1,8 @@
 Release 4.1.0 - unreleased
 
+   * PDF: a bookmark outline is now walked without recursion, and lists nest at
+     most 50 deep with deeper items joining the deepest list (TIKA-4894).
+
    * A text recognizer is an engine: configure Tesseract, Tess4J or a VLM once
      under "engines" and name it from "text-recognizers" with
      {"engine": "<name>"} (plus _mime-include/_mime-exclude). The 4.0 form with
diff --git 
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-pdf-module/src/main/java/org/apache/tika/parser/pdf/AbstractPDF2XHTML.java
 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-pdf-module/src/main/java/org/apache/tika/parser/pdf/AbstractPDF2XHTML.java
index 961f3fc967..725f63f508 100644
--- 
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-pdf-module/src/main/java/org/apache/tika/parser/pdf/AbstractPDF2XHTML.java
+++ 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-pdf-module/src/main/java/org/apache/tika/parser/pdf/AbstractPDF2XHTML.java
@@ -34,10 +34,12 @@ import java.nio.file.Files;
 import java.nio.file.Path;
 import java.nio.file.StandardOpenOption;
 import java.text.SimpleDateFormat;
+import java.util.ArrayDeque;
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.Calendar;
 import java.util.Collections;
+import java.util.Deque;
 import java.util.HashSet;
 import java.util.List;
 import java.util.ListIterator;
@@ -154,6 +156,8 @@ class AbstractPDF2XHTML extends PDFTextStripper {
      */
     private final static int MAX_RECURSION_DEPTH = 100;
     private final static int MAX_BOOKMARK_ITEMS = 10000;
+    /** Deeper bookmarks are written as items of the deepest list, within the 
XML nesting limit. */
+    private final static int MAX_BOOKMARK_DEPTH = 50;
 
     //This is used for both types and subtypes.
     //These can be unbounded.  We need to limit the number we store.
@@ -1476,41 +1480,62 @@ class AbstractPDF2XHTML extends PDFTextStripper {
     void extractBookmarkText() throws SAXException, IOException, TikaException 
{
         PDDocumentOutline outline = 
document.getDocumentCatalog().getDocumentOutline();
         if (outline != null) {
-            Set<COSObjectable> seen = new HashSet<>();
-            extractBookmarkText(outline, seen, 0);
+            extractBookmarkText(outline);
         }
     }
 
-    void extractBookmarkText(PDOutlineNode bookmark, Set<COSObjectable> seen, 
int itemCount)
+    /**
+     * Walks the outline without recursion: a chain of items each the only 
child of the last
+     * can be as long as the item budget, and a hostile file makes it so. A 
list opens for the
+     * children of an item up to {@link #MAX_BOOKMARK_DEPTH}; deeper items 
join the deepest
+     * list. Cycles end where an item is met a second time.
+     */
+    private void extractBookmarkText(PDOutlineNode outline)
             throws SAXException, IOException, TikaException {
-        PDOutlineItem current = bookmark.getFirstChild();
-        if (itemCount > MAX_BOOKMARK_ITEMS) {
+        PDOutlineItem current = outline.getFirstChild();
+        if (current == null) {
             return;
         }
-        if (current != null) {
-            if (seen.contains(current)) {
-                return;
-            }
-            xhtml.startElement("ul");
-            while (current != null) {
-                if (seen.contains(current)) {
+        Set<COSObjectable> seen = new HashSet<>();
+        // the item whose children are being walked, per level; whether its 
level opened a list
+        Deque<PDOutlineItem> parents = new ArrayDeque<>();
+        Deque<Boolean> opened = new ArrayDeque<>();
+        xhtml.startElement("ul");
+        int lists = 1;
+        int items = 0;
+        while (true) {
+            if (current == null || seen.contains(current) || items > 
MAX_BOOKMARK_ITEMS) {
+                if (parents.isEmpty()) {
                     break;
                 }
-                if (itemCount > MAX_BOOKMARK_ITEMS) {
-                    break;
+                if (opened.pop()) {
+                    xhtml.endElement("ul");
+                    lists--;
                 }
-                seen.add(current);
-                xhtml.startElement("li");
-                xhtml.characters(current.getTitle());
-                xhtml.endElement("li");
-                handleDestinationOrAction(current.getAction(), 
ActionTrigger.BOOKMARK);
-                // Recurse:
-                extractBookmarkText(current, seen, itemCount + 1);
+                current = parents.pop().getNextSibling();
+                continue;
+            }
+            seen.add(current);
+            items++;
+            xhtml.startElement("li");
+            xhtml.characters(current.getTitle());
+            xhtml.endElement("li");
+            handleDestinationOrAction(current.getAction(), 
ActionTrigger.BOOKMARK);
+            PDOutlineItem child = current.getFirstChild();
+            if (child != null && !seen.contains(child)) {
+                boolean open = lists < MAX_BOOKMARK_DEPTH;
+                if (open) {
+                    xhtml.startElement("ul");
+                    lists++;
+                }
+                parents.push(current);
+                opened.push(open);
+                current = child;
+            } else {
                 current = current.getNextSibling();
-                itemCount++;
             }
-            xhtml.endElement("ul");
         }
+        xhtml.endElement("ul");
     }
 
     void extractAcroForm(PDDocument pdf) throws IOException, SAXException, 
TikaException {
diff --git 
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-pdf-module/src/test/java/org/apache/tika/parser/pdf/PDFParserTest.java
 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-pdf-module/src/test/java/org/apache/tika/parser/pdf/PDFParserTest.java
index 56333f89bd..b48638488e 100644
--- 
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-pdf-module/src/test/java/org/apache/tika/parser/pdf/PDFParserTest.java
+++ 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-pdf-module/src/test/java/org/apache/tika/parser/pdf/PDFParserTest.java
@@ -26,6 +26,7 @@ import static org.junit.jupiter.api.Assertions.assertThrows;
 import static org.junit.jupiter.api.Assertions.assertTrue;
 
 import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
 import java.io.IOException;
 import java.nio.charset.StandardCharsets;
 import java.nio.file.Files;
@@ -44,6 +45,12 @@ import java.util.regex.Matcher;
 import java.util.regex.Pattern;
 
 import org.apache.commons.io.IOUtils;
+import org.apache.pdfbox.pdmodel.PDDocument;
+import org.apache.pdfbox.pdmodel.PDPage;
+import org.apache.pdfbox.pdmodel.common.PDRectangle;
+import 
org.apache.pdfbox.pdmodel.interactive.documentnavigation.outline.PDDocumentOutline;
+import 
org.apache.pdfbox.pdmodel.interactive.documentnavigation.outline.PDOutlineItem;
+import 
org.apache.pdfbox.pdmodel.interactive.documentnavigation.outline.PDOutlineNode;
 import org.junit.jupiter.api.AfterAll;
 import org.junit.jupiter.api.BeforeAll;
 import org.junit.jupiter.api.Disabled;
@@ -583,6 +590,64 @@ public class PDFParserTest extends TikaTest {
         assertTrue(i < j);
     }
 
+    /**
+     * An outline as deep as the item budget allows, each item the only child 
of the last,
+     * on a thread with a stack a quarter of the default: every title is 
written, lists nest
+     * within the XML limit, and a cycle ends the walk.
+     */
+    @Test
+    public void testDeepAndCyclicBookmarks() throws Exception {
+        try (PDDocument doc = new PDDocument()) {
+            doc.addPage(new PDPage(PDRectangle.LETTER));
+            PDDocumentOutline outline = new PDDocumentOutline();
+            doc.getDocumentCatalog().setDocumentOutline(outline);
+            PDOutlineNode parent = outline;
+            PDOutlineItem first = null;
+            for (int i = 0; i < 9000; i++) {
+                PDOutlineItem item = new PDOutlineItem();
+                item.setTitle("Level " + i);
+                parent.addLast(item);
+                if (first == null) {
+                    first = item;
+                }
+                parent = item;
+            }
+            // the deepest item's child is the first: a cycle
+            parent.addLast(first);
+            ByteArrayOutputStream bos = new ByteArrayOutputStream();
+            doc.save(bos);
+
+            String[] xml = new String[1];
+            Throwable[] failure = new Throwable[1];
+            Thread thread = new Thread(null, () -> {
+                try (TikaInputStream tis = 
TikaInputStream.get(bos.toByteArray())) {
+                    ToXMLContentHandler handler = new ToXMLContentHandler();
+                    new PDFParser().parse(tis, handler, new Metadata(), new 
ParseContext());
+                    xml[0] = handler.toString();
+                } catch (Throwable t) {
+                    failure[0] = t;
+                }
+            }, "bookmarks", 256 * 1024);
+            thread.start();
+            thread.join();
+            assertNull(failure[0], String.valueOf(failure[0]));
+            assertContains("<li>Level 0</li>", xml[0]);
+            assertContains("<li>Level 8999</li>", xml[0]);
+            assertEquals(9000, xml[0].split("<li>Level ").length - 1);
+            int depth = 0;
+            int deepest = 0;
+            for (int i = xml[0].indexOf("<body>"); i >= 0 && i < 
xml[0].length(); i++) {
+                if (xml[0].startsWith("<ul>", i)) {
+                    deepest = Math.max(deepest, ++depth);
+                } else if (xml[0].startsWith("</ul>", i)) {
+                    depth--;
+                }
+            }
+            assertEquals(0, depth);
+            assertEquals(50, deepest);
+        }
+    }
+
     // TIKA-2303
     @Test
     public void testTurningOffBookmarks() throws Exception {

Reply via email to