This is an automated email from the ASF dual-hosted git repository.

tballison pushed a commit to branch branch_3x
in repository https://gitbox.apache.org/repos/asf/tika.git


The following commit(s) were added to refs/heads/branch_3x by this push:
     new 98feab84e7 TIKA-4894: walk PDF bookmark outlines without recursion 
(#3167) (#3274)
98feab84e7 is described below

commit 98feab84e747cd67d8ee9361a4a24e0b4bc6bc14
Author: Tim Allison <[email protected]>
AuthorDate: Mon Sep 28 16:42:15 2026 -0400

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

diff --git a/CHANGES.txt b/CHANGES.txt
index 64baa29f35..5c68587737 100644
--- a/CHANGES.txt
+++ b/CHANGES.txt
@@ -1,5 +1,8 @@
 Release 3.3.3 - ???
 
+  * 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).
+
   * MagicDetector now compiles its regular expression once, in the
     constructor, instead of recompiling it on every match (TIKA-4796).
 
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 6037756e84..257f269e3e 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,9 +34,11 @@ 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.Calendar;
 import java.util.Collections;
+import java.util.Deque;
 import java.util.HashSet;
 import java.util.List;
 import java.util.ListIterator;
@@ -145,6 +147,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.
@@ -1212,41 +1216,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 2e4419495d..f905354b22 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
@@ -25,6 +25,7 @@ import static org.junit.jupiter.api.Assertions.assertThrows;
 import static org.junit.jupiter.api.Assertions.assertTrue;
 import static org.junit.jupiter.api.Assertions.fail;
 
+import java.io.ByteArrayOutputStream;
 import java.io.InputStream;
 import java.util.Arrays;
 import java.util.HashMap;
@@ -38,6 +39,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;
@@ -52,6 +59,7 @@ import org.apache.tika.exception.EncryptedDocumentException;
 import org.apache.tika.exception.TikaException;
 import org.apache.tika.exception.ZeroByteFileException;
 import org.apache.tika.extractor.DocumentSelector;
+import org.apache.tika.io.TikaInputStream;
 import org.apache.tika.metadata.Font;
 import org.apache.tika.metadata.Metadata;
 import org.apache.tika.metadata.PDF;
@@ -70,6 +78,7 @@ import org.apache.tika.parser.Parser;
 import org.apache.tika.parser.PasswordProvider;
 import org.apache.tika.sax.BodyContentHandler;
 import org.apache.tika.sax.ContentHandlerDecorator;
+import org.apache.tika.sax.ToXMLContentHandler;
 import org.apache.tika.utils.ExceptionUtils;
 
 /**
@@ -562,6 +571,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