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 {