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 {