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 726bb2a841 TIKA-4878: pdf and pst (#3133)
726bb2a841 is described below

commit 726bb2a841a6bdfef942afef15cc8424f355e91d
Author: Tim Allison <[email protected]>
AuthorDate: Fri Sep 4 14:04:24 2026 -0400

    TIKA-4878: pdf and pst (#3133)
---
 CHANGES.txt                                        |   7 ++
 .../parser/microsoft/pst/PSTMailItemParser.java    |  18 ++-
 .../apache/tika/parser/pdf/AbstractPDF2XHTML.java  |  19 ++-
 .../parser/pdf/PDFEmbeddedFileNoTempFileTest.java  | 135 +++++++++++++++++++++
 4 files changed, 167 insertions(+), 12 deletions(-)

diff --git a/CHANGES.txt b/CHANGES.txt
index c24adef94e..a3aaaebafd 100644
--- a/CHANGES.txt
+++ b/CHANGES.txt
@@ -1,5 +1,12 @@
 Release 4.1.0 - unreleased
 
+   * PDF attachments, PDF XMP packets, 3D on-instantiate scripts and PST
+     attachments are re-opened from their document instead of cached when
+     the embedded-document extractor rewinds them (digesting does, for every
+     embedded document). The cached copy cost heap for the whole attachment
+     and, past the cache budget or the 1 MB floor, a temp file; PDFBox and
+     java-libpst hand the bytes back on demand (TIKA-4878).
+
    * tika-server: named configuration presets (TIKA-4856).
 
    * Temp files follow -Djava.io.tmpdir on the parent JVM (Tika, its
diff --git 
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/pst/PSTMailItemParser.java
 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/pst/PSTMailItemParser.java
index 509000babb..72b85d73af 100644
--- 
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/pst/PSTMailItemParser.java
+++ 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/pst/PSTMailItemParser.java
@@ -35,6 +35,7 @@ import org.apache.tika.annotation.TikaComponent;
 import org.apache.tika.exception.TikaException;
 import org.apache.tika.extractor.EmbeddedDocumentExtractor;
 import org.apache.tika.extractor.EmbeddedDocumentUtil;
+import org.apache.tika.io.TemporaryResources;
 import org.apache.tika.io.TikaInputStream;
 import org.apache.tika.metadata.HttpHeaders;
 import org.apache.tika.metadata.MAPI;
@@ -264,18 +265,23 @@ public class PSTMailItemParser implements Parser {
         attributes.addAttribute("", "id", "id", "CDATA", filename);
         xhtml.startElement("div", attributes);
         if (embeddedExtractor.shouldParseEmbedded(attachMeta, context)) {
-            TikaInputStream tis = null;
+            //open once now so a broken attachment is recorded here, not 
mid-parse
             try {
-                tis = TikaInputStream.get(attachment.getFileInputStream());
+                attachment.getFileInputStream().close();
             } catch (NullPointerException e) { //TIKA-2488
                 EmbeddedDocumentUtil.recordEmbeddedStreamException(e, 
metadata, context);
                 return;
             }
-
-            try {
+            //the attachment is in the pst already: re-open it on rewind 
rather than
+            //cache a copy that a digest of a large attachment would spill to 
disk
+            try (TikaInputStream tis = TikaInputStream.get(() -> {
+                try {
+                    return attachment.getFileInputStream();
+                } catch (PSTException e) {
+                    throw new IOException(e);
+                }
+            }, new TemporaryResources(), null)) {
                 embeddedExtractor.parseEmbedded(tis, xhtml, attachMeta, 
context, false);
-            } finally {
-                tis.close();
             }
         }
         xhtml.endElement("div");
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 7ad8d52e56..c3b60f8ab4 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
@@ -282,7 +282,8 @@ class AbstractPDF2XHTML extends PDFTextStripper {
             //try the main metadata
             if (pdDocument.getDocumentCatalog().getMetadata() != null) {
                 try (TikaInputStream tis = TikaInputStream.get(
-                        
pdDocument.getDocumentCatalog().getMetadata().exportXMPMetadata())) {
+                        () -> 
pdDocument.getDocumentCatalog().getMetadata().exportXMPMetadata(),
+                        new TemporaryResources(), null)) {
                     extractXMPAsEmbeddedFile(tis, 
XMP_DOCUMENT_CATALOG_LOCATION);
                 } catch (IOException e) {
                     EmbeddedDocumentUtil.recordEmbeddedStreamException(e, 
metadata, context);
@@ -292,7 +293,9 @@ class AbstractPDF2XHTML extends PDFTextStripper {
             int pageNumber = 1;
             for (PDPage page : pdDocument.getPages()) {
                 if (page.getMetadata() != null) {
-                    try (TikaInputStream tis = 
TikaInputStream.get(page.getMetadata().exportXMPMetadata())) {
+                    try (TikaInputStream tis = TikaInputStream.get(
+                            () -> page.getMetadata().exportXMPMetadata(),
+                            new TemporaryResources(), null)) {
                         extractXMPAsEmbeddedFile(tis, XMP_PAGE_LOCATION_PREFIX 
+ pageNumber);
                     } catch (IOException e) {
                         EmbeddedDocumentUtil.recordEmbeddedStreamException(e, 
metadata, context);
@@ -500,14 +503,17 @@ class AbstractPDF2XHTML extends PDFTextStripper {
         if (!embeddedDocumentExtractor.shouldParseEmbedded(embeddedMetadata, 
context)) {
             return;
         }
-        TikaInputStream tis = null;
+        //open once now so a broken stream is recorded here, not mid-parse
         try {
-            tis = TikaInputStream.get(pdEmbeddedFile.createInputStream());
+            pdEmbeddedFile.createInputStream().close();
         } catch (IOException e) {
-            //store this exception in the parent's metadata
             EmbeddedDocumentUtil.recordEmbeddedStreamException(e, metadata, 
context);
             return;
         }
+        //the file is in the document already: re-open (re-decode) it on rewind
+        //rather than cache a copy that a digest of a large attachment would 
spill
+        TikaInputStream tis = 
TikaInputStream.get(pdEmbeddedFile::createInputStream,
+                new TemporaryResources(), null);
 
         setOrReplaceAttribute("class", "embedded", attributes);
         setOrReplaceAttribute("id", fileName, attributes);
@@ -945,7 +951,8 @@ class AbstractPDF2XHTML extends PDFTextStripper {
         }
         Metadata m = getJavascriptMetadata("3DD_ON_INSTANTIATE", null, null);
         if (embeddedDocumentExtractor.shouldParseEmbedded(m, context)) {
-            try (TikaInputStream tis = 
TikaInputStream.get(stream.createInputStream())) {
+            try (TikaInputStream tis = 
TikaInputStream.get(stream::createInputStream,
+                    new TemporaryResources(), null)) {
                 embeddedDocumentExtractor.parseEmbedded(tis, xhtml, m, 
context, true);
             }
         }
diff --git 
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-pdf-module/src/test/java/org/apache/tika/parser/pdf/PDFEmbeddedFileNoTempFileTest.java
 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-pdf-module/src/test/java/org/apache/tika/parser/pdf/PDFEmbeddedFileNoTempFileTest.java
new file mode 100644
index 0000000000..03ddc6b343
--- /dev/null
+++ 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-pdf-module/src/test/java/org/apache/tika/parser/pdf/PDFEmbeddedFileNoTempFileTest.java
@@ -0,0 +1,135 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.tika.parser.pdf;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.io.ByteArrayInputStream;
+import java.io.IOException;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import java.util.Random;
+
+import org.apache.pdfbox.cos.COSName;
+import org.apache.pdfbox.pdmodel.PDDocument;
+import org.apache.pdfbox.pdmodel.PDDocumentNameDictionary;
+import org.apache.pdfbox.pdmodel.PDEmbeddedFilesNameTreeNode;
+import org.apache.pdfbox.pdmodel.PDPage;
+import 
org.apache.pdfbox.pdmodel.common.filespecification.PDComplexFileSpecification;
+import org.apache.pdfbox.pdmodel.common.filespecification.PDEmbeddedFile;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+import org.xml.sax.ContentHandler;
+import org.xml.sax.helpers.DefaultHandler;
+
+import org.apache.tika.TikaTest;
+import org.apache.tika.extractor.EmbeddedDocumentExtractor;
+import org.apache.tika.io.TikaInputStream;
+import org.apache.tika.metadata.Metadata;
+import org.apache.tika.parser.ParseContext;
+
+/**
+ * A PDF attachment's bytes are in the document already. Rewinding the stream
+ * handed to the embedded-document extractor -- which digesting does for every
+ * embedded document -- must re-open (re-decode) the attachment from the
+ * document, not cache a copy of it and spill that copy to a temp file.
+ * <p>
+ * The payload is over the 1 MB a cache keeps in memory, so a cached stream has
+ * to spill to rewind and the difference is observable. The assertion is on the
+ * stream the extractor is handed: the parser owns the child's
+ * {@code TemporaryResources}, so a watched directory would pass either way.
+ */
+public class PDFEmbeddedFileNoTempFileTest extends TikaTest {
+
+    private static final int PAYLOAD_LENGTH = 2 * 1024 * 1024;
+
+    @TempDir
+    Path tempDir;
+
+    @Test
+    public void testAttachmentIsNotSpooled() throws Exception {
+        Path pdf = tempDir.resolve("attachment.pdf");
+        byte[] payload = payload();
+        try (PDDocument doc = new PDDocument()) {
+            doc.addPage(new PDPage());
+            //Flate-encoded, as real attachments are: a rewind has to re-decode
+            PDEmbeddedFile file = new PDEmbeddedFile(doc, new 
ByteArrayInputStream(payload),
+                    COSName.FLATE_DECODE);
+            file.setSize(payload.length);
+            PDComplexFileSpecification spec = new PDComplexFileSpecification();
+            spec.setFile("attachment.bin");
+            spec.setEmbeddedFile(file);
+            PDEmbeddedFilesNameTreeNode tree = new 
PDEmbeddedFilesNameTreeNode();
+            tree.setNames(Map.of("attachment.bin", spec));
+            PDDocumentNameDictionary names = new 
PDDocumentNameDictionary(doc.getDocumentCatalog());
+            names.setEmbeddedFiles(tree);
+            doc.getDocumentCatalog().setNames(names);
+            doc.save(pdf.toFile());
+        }
+
+        RecordingExtractor extractor = new RecordingExtractor();
+        ParseContext context = new ParseContext();
+        context.set(EmbeddedDocumentExtractor.class, extractor);
+        Metadata metadata = new Metadata();
+        try (TikaInputStream tis = TikaInputStream.get(pdf, metadata)) {
+            new PDFParser().parse(tis, new DefaultHandler(), metadata, 
context);
+        }
+
+        assertTrue(extractor.lengths.contains(PAYLOAD_LENGTH),
+                "the attachment reached the extractor in full; saw " + 
extractor.lengths);
+        for (int i = 0; i < extractor.spooled.size(); i++) {
+            assertEquals(false, extractor.spooled.get(i), "embedded stream " + 
i + " ("
+                    + extractor.lengths.get(i)
+                    + " bytes) was spooled to disk to rewind instead of 
re-opened");
+        }
+    }
+
+    /** Incompressible filler, so Flate keeps it at full size. */
+    private static byte[] payload() {
+        byte[] bytes = new byte[PAYLOAD_LENGTH];
+        new Random(4878).nextBytes(bytes);
+        return bytes;
+    }
+
+    /**
+     * Rewinds each embedded stream the way a digester does, then records 
whether
+     * that left it backed by a temp file and how many bytes it still yields.
+     */
+    private static class RecordingExtractor implements 
EmbeddedDocumentExtractor {
+        private final List<Boolean> spooled = new ArrayList<>();
+        private final List<Integer> lengths = new ArrayList<>();
+
+        @Override
+        public boolean shouldParseEmbedded(Metadata metadata, ParseContext 
context) {
+            return true;
+        }
+
+        @Override
+        public void parseEmbedded(TikaInputStream stream, ContentHandler 
handler,
+                                  Metadata metadata, ParseContext context, 
boolean outputHtml)
+                throws IOException {
+            stream.enableRewind();
+            stream.readAllBytes();
+            stream.rewind();
+            spooled.add(stream.hasFile());
+            lengths.add(stream.readAllBytes().length);
+        }
+    }
+}

Reply via email to