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 c1dc3f29c4 TIKA-4835 spill sites (#3079)
c1dc3f29c4 is described below
commit c1dc3f29c4b03c40c35fe0eea77614f8cb8c7e3e
Author: Tim Allison <[email protected]>
AuthorDate: Wed Aug 26 18:13:28 2026 -0400
TIKA-4835 spill sites (#3079)
---
CHANGES.txt | 2 +
docs/modules/ROOT/pages/advanced/spooling.adoc | 30 +++-
docs/modules/ROOT/pages/pipes/performance.adoc | 5 +-
.../tika/parser/microsoft/rtf/RTFParserTest.java | 4 +-
.../tika/parser/image/AbstractImageParser.java | 6 +-
.../apache/tika/parser/image/ByteBufferReader.java | 74 ++++++++++
.../tika/parser/image/ImageMetadataExtractor.java | 80 ++++++++++-
.../org/apache/tika/parser/image/ImageXmp.java | 55 ++++----
.../org/apache/tika/parser/image/JpegParser.java | 8 +-
.../org/apache/tika/parser/image/TiffParser.java | 6 +-
.../org/apache/tika/parser/image/WebPParser.java | 7 +-
.../parser/image/ImageParsersNoTempFileTest.java | 143 +++++++++++++++++++
.../detect/microsoft/POIFSContainerDetector.java | 147 ++++++++++++++++++--
.../POIFSContainerDetectorNoTempFileTest.java | 116 ++++++++++++++++
.../detect/microsoft/POIFSDeclaredSizeTest.java | 154 +++++++++++++++++++++
.../apache/tika/parser/odf/OpenDocumentParser.java | 6 +-
.../odf/OpenDocumentParserNoTempFileTest.java | 104 ++++++++++++++
.../java/org/apache/tika/parser/pdf/PDFParser.java | 43 ++++--
.../apache/tika/parser/pdf/PDFRandomAccess.java | 79 +++++++++++
.../tika/renderer/pdf/pdfbox/PDFBoxRenderer.java | 19 ++-
.../tika/parser/pdf/PDFParserNoTempFileTest.java | 71 ++++++++++
.../tika/parser/pdf/PDFPerPageRenderTest.java | 95 +++++++++++++
22 files changed, 1175 insertions(+), 79 deletions(-)
diff --git a/CHANGES.txt b/CHANGES.txt
index 13bed521c1..b7514b3cc7 100644
--- a/CHANGES.txt
+++ b/CHANGES.txt
@@ -1,5 +1,7 @@
Release 4.1.0 - unreleased
+ * Improve spooling/decrease number of spills to disk (TIKA-4835).
+
* Fixed a bug that made per-request (parse-context) configuration unusable
for parsers that lock some config fields against caller modification --
Tess4J, the VLM parsers and the OpenAI image-embedding parser. Any such
diff --git a/docs/modules/ROOT/pages/advanced/spooling.adoc
b/docs/modules/ROOT/pages/advanced/spooling.adoc
index d3fc2d67ba..c66a4b191f 100644
--- a/docs/modules/ROOT/pages/advanced/spooling.adoc
+++ b/docs/modules/ROOT/pages/advanced/spooling.adoc
@@ -49,16 +49,36 @@ Several file formats are most efficiently processed with
random access vs stream
The current architecture follows a simple principle: **each component that
needs random
access is responsible for obtaining it**.
-A detector or parser is handed a `TikaInputStream`; when it needs random access
-it simply asks for a file:
+A detector or parser is handed a `TikaInputStream`; when it needs random
access it asks for
+a seekable view of the content and lets `TikaInputStream` decide whether that
view is backed
+by memory or by a file:
[source,java]
----
-Path path = tis.getPath();
-// or
-File file = tis.getFile();
+try (SeekableByteChannel channel = tis.getSeekableByteChannel()) {
+ ByteBuffer view = TikaInputStream.inMemoryContent(channel);
+ if (view != null) {
+ readInPlace(view); // in memory: no copy, no temp file
+ } else {
+ readFromFile(tis.getFile()); // spilled: the file already exists
+ }
+}
----
+Two rules go with that form:
+
+* The `ByteBuffer` aliases the cache's own array and is valid *only while that
channel is
+ open*. Keep the channel open for as long as the view is in use. A view that
outlives its
+ channel still reads correctly but is no longer counted against the memory
budget.
+* `inMemoryContent()` returns `null` whenever the content is not in memory --
file-backed
+ input, or a cache that spilled. That is the normal case for large content,
not an error.
+
+`getFile()` remains correct when a consumer genuinely requires a
`java.io.File` -- many
+third-party libraries do -- but it always materialises one. Prefer the
channel/view form when
+the consumer can accept a buffer or a stream. For a worked example see
+`PDFRandomAccess.open(...)` in `tika-parser-pdf-module`, one factory that
makes this choice
+for the PDF parser, its incremental-update scan, and the renderer.
+
`TikaInputStream` handles the spooling transparently based on how it was
initialized:
* **Initialized with `Path`**: The file is used directly for random access. No
spooling needed.
diff --git a/docs/modules/ROOT/pages/pipes/performance.adoc
b/docs/modules/ROOT/pages/pipes/performance.adoc
index f35296d633..8f40cb5168 100644
--- a/docs/modules/ROOT/pages/pipes/performance.adoc
+++ b/docs/modules/ROOT/pages/pipes/performance.adoc
@@ -242,8 +242,9 @@ architecture suggested it would be.
* Several parsers and detectors asked for a `java.io.File` even when the
document was already in memory: the JPEG/TIFF/WebP metadata extractors, the
OLE2 container detector, the OpenDocument parser's inline pictures, the
- digest of translated embedded streams, and the PDF incremental-update scan
- each wrote the bytes out just to read them back.
+ digest of translated embedded streams, the PDF incremental-update scan, and
+ PDFParser's main document load and renderer each wrote the bytes out just to
+ read them back.
On a spinning-disk host where the temp directory, the corpus, and the outputs
share spindles, every temp byte is a seek taken away from a corpus read or an
diff --git
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-integration-tests/src/test/java/org/apache/tika/parser/microsoft/rtf/RTFParserTest.java
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-integration-tests/src/test/java/org/apache/tika/parser/microsoft/rtf/RTFParserTest.java
index 96ba2b3c7b..9b4fe1b5ea 100644
---
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-integration-tests/src/test/java/org/apache/tika/parser/microsoft/rtf/RTFParserTest.java
+++
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-integration-tests/src/test/java/org/apache/tika/parser/microsoft/rtf/RTFParserTest.java
@@ -145,7 +145,9 @@ public class RTFParserTest extends TikaTest {
//need flexibility for if tesseract is installed or not
//TODO -- fix this test. It is too fragile.
- assertTrue(meta_jpg.names().length >= 52 && meta_jpg.names().length <=
60);
+ // in-memory embedded images no longer carry metadata-extractor's
temp-file
+ // name/size/date tags (TIKA-4835), hence the lower bound
+ assertTrue(meta_jpg.names().length >= 49 && meta_jpg.names().length <=
60);
assertTrue(meta_jpg_exif.names().length >= 100 &&
meta_jpg_exif.names().length <= 130);
}
diff --git
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-image-module/src/main/java/org/apache/tika/parser/image/AbstractImageParser.java
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-image-module/src/main/java/org/apache/tika/parser/image/AbstractImageParser.java
index 4ae61217c4..954649b2b0 100644
---
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-image-module/src/main/java/org/apache/tika/parser/image/AbstractImageParser.java
+++
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-image-module/src/main/java/org/apache/tika/parser/image/AbstractImageParser.java
@@ -18,7 +18,6 @@ package org.apache.tika.parser.image;
import java.io.IOException;
import java.io.InputStream;
-import java.nio.file.Files;
import java.nio.file.Path;
import org.xml.sax.ContentHandler;
@@ -89,7 +88,10 @@ public abstract class AbstractImageParser implements Parser {
XHTMLContentHandler xhtml = new XHTMLContentHandler(handler,
metadata, context);
xhtml.startDocument();
Path path = tis.getPath();
- try (InputStream pathStream = Files.newInputStream(path)) {
+ // a TikaInputStream over the path, not a raw stream: the content
is already on
+ // disk, so this takes the file path in extractMetadata instead of
caching a
+ // second copy in memory whose budget reservation nothing here
would release
+ try (TikaInputStream pathStream = TikaInputStream.get(path)) {
extractMetadata(pathStream, new EmbeddedContentHandler(xhtml),
metadata, context);
} catch (SecurityException e) {
throw e;
diff --git
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-image-module/src/main/java/org/apache/tika/parser/image/ByteBufferReader.java
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-image-module/src/main/java/org/apache/tika/parser/image/ByteBufferReader.java
new file mode 100644
index 0000000000..43da7a2840
--- /dev/null
+++
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-image-module/src/main/java/org/apache/tika/parser/image/ByteBufferReader.java
@@ -0,0 +1,74 @@
+/*
+ * 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.image;
+
+import java.nio.ByteBuffer;
+
+import com.drew.lang.BufferBoundsException;
+import com.drew.lang.RandomAccessReader;
+
+/**
+ * drewnoakes random-access reader over a {@link ByteBuffer}, for content
already in memory.
+ * The library's own stream reader retains every chunk it reads and its TIFF
reader asks for
+ * the length up front, which reads the whole stream; this reads the buffer in
place.
+ */
+final class ByteBufferReader extends RandomAccessReader {
+
+ private final ByteBuffer buffer;
+
+ /** Reads {@code buffer} from position 0 to its limit; the buffer's
position is not used. */
+ ByteBufferReader(ByteBuffer buffer) {
+ this.buffer = buffer;
+ }
+
+ @Override
+ public int toUnshiftedOffset(int localOffset) {
+ return localOffset;
+ }
+
+ @Override
+ public long getLength() {
+ return buffer.limit();
+ }
+
+ @Override
+ public byte getByte(int index) throws java.io.IOException {
+ validateIndex(index, 1);
+ return buffer.get(index);
+ }
+
+ @Override
+ public byte[] getBytes(int index, int count) throws java.io.IOException {
+ validateIndex(index, count);
+ byte[] bytes = new byte[count];
+ buffer.get(index, bytes);
+ return bytes;
+ }
+
+ @Override
+ protected boolean isValidIndex(int index, int bytesRequested) {
+ return bytesRequested >= 0 && index >= 0 &&
+ (long) index + bytesRequested - 1L < buffer.limit();
+ }
+
+ @Override
+ protected void validateIndex(int index, int bytesRequested) throws
java.io.IOException {
+ if (!isValidIndex(index, bytesRequested)) {
+ throw new BufferBoundsException(index, bytesRequested,
buffer.limit());
+ }
+ }
+}
diff --git
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-image-module/src/main/java/org/apache/tika/parser/image/ImageMetadataExtractor.java
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-image-module/src/main/java/org/apache/tika/parser/image/ImageMetadataExtractor.java
index 3272f8d72f..41f2b4bca3 100644
---
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-image-module/src/main/java/org/apache/tika/parser/image/ImageMetadataExtractor.java
+++
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-image-module/src/main/java/org/apache/tika/parser/image/ImageMetadataExtractor.java
@@ -19,6 +19,8 @@ package org.apache.tika.parser.image;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
+import java.nio.ByteBuffer;
+import java.nio.channels.SeekableByteChannel;
import java.text.DecimalFormat;
import java.text.DecimalFormatSymbols;
import java.text.SimpleDateFormat;
@@ -41,6 +43,7 @@ import com.drew.imaging.tiff.TiffProcessingException;
import com.drew.imaging.webp.WebpMetadataReader;
import com.drew.lang.ByteArrayReader;
import com.drew.lang.GeoLocation;
+import com.drew.lang.RandomAccessReader;
import com.drew.lang.Rational;
import com.drew.metadata.Directory;
import com.drew.metadata.MetadataException;
@@ -70,6 +73,7 @@ import org.apache.commons.io.IOUtils;
import org.xml.sax.SAXException;
import org.apache.tika.exception.TikaException;
+import org.apache.tika.io.TikaInputStream;
import org.apache.tika.metadata.Geographic;
import org.apache.tika.metadata.IPTC;
import org.apache.tika.metadata.KeyPrefix;
@@ -135,6 +139,50 @@ public class ImageMetadataExtractor {
new ExifReader(), new IccReader(), new PhotoshopReader(), new
DuckyReader(),
new IptcReader(), new AdobeJpegReader(), new JpegDhtReader(), new
JpegDnlReader());
+ /**
+ * Reads from the file when one already exists (that path also yields
metadata-extractor's
+ * file-system tags: img:File Name/Size/Modified Date, which describe the
file, not the
+ * image), and from the stream otherwise. JPEG is read sequentially, so
the stream costs
+ * no extra memory.
+ */
+ public void parseJpeg(TikaInputStream tis) throws IOException,
SAXException, TikaException {
+ if (tis.hasFile()) {
+ parseJpeg(tis.getFile());
+ } else {
+ parseJpeg((InputStream) tis);
+ }
+ }
+
+ /**
+ * TIFF needs random access. In-memory content is read in place through a
zero-copy view;
+ * anything else is read from the file. There is deliberately no
InputStream overload:
+ * metadata-extractor's stream reader retains everything it reads.
+ */
+ public void parseTiff(TikaInputStream tis) throws IOException,
SAXException, TikaException {
+ if (tis.hasFile()) {
+ parseTiff(tis.getFile());
+ return;
+ }
+ try (SeekableByteChannel channel = tis.getSeekableByteChannel()) {
+ ByteBuffer view = TikaInputStream.inMemoryContent(channel);
+ if (view != null) {
+ parseTiff(new ByteBufferReader(view));
+ return;
+ }
+ }
+ // the drain spilled: the content is on disk now
+ parseTiff(tis.getFile());
+ }
+
+ /** See {@link #parseJpeg(TikaInputStream)}; WebP is read sequentially
too. */
+ public void parseWebP(TikaInputStream tis) throws IOException,
TikaException {
+ if (tis.hasFile()) {
+ parseWebP(tis.getFile());
+ } else {
+ parseWebP((InputStream) tis);
+ }
+ }
+
public void parseJpeg(File file) throws IOException, SAXException,
TikaException {
try {
com.drew.metadata.Metadata jpegMetadata =
@@ -145,23 +193,41 @@ public class ImageMetadataExtractor {
}
}
+ public void parseJpeg(InputStream stream) throws IOException,
SAXException, TikaException {
+ try {
+ handle(JpegMetadataReader.readMetadata(stream,
JPEG_READERS_NO_XMP));
+ } catch (JpegProcessingException | MetadataException e) {
+ throw new TikaException("Can't read JPEG metadata", e);
+ }
+ }
+
public void parseTiff(File file) throws IOException, SAXException,
TikaException {
try {
- com.drew.metadata.Metadata tiffMetadata =
TiffMetadataReader.readMetadata(file);
- handle(tiffMetadata);
+ handle(TiffMetadataReader.readMetadata(file));
+ } catch (MetadataException | TiffProcessingException e) {
+ throw new TikaException("Can't read TIFF metadata", e);
+ }
+ }
+
+ private void parseTiff(RandomAccessReader reader) throws IOException,
SAXException, TikaException {
+ try {
+ handle(TiffMetadataReader.readMetadata(reader));
} catch (MetadataException | TiffProcessingException e) {
throw new TikaException("Can't read TIFF metadata", e);
}
}
public void parseWebP(File file) throws IOException, TikaException {
+ try {
+ handle(WebpMetadataReader.readMetadata(file));
+ } catch (RiffProcessingException | MetadataException e) {
+ throw new TikaException("Can't process Riff data", e);
+ }
+ }
+ public void parseWebP(InputStream stream) throws IOException,
TikaException {
try {
- com.drew.metadata.Metadata webPMetadata = new
com.drew.metadata.Metadata();
- webPMetadata = WebpMetadataReader.readMetadata(file);
- handle(webPMetadata);
- } catch (IOException e) {
- throw e;
+ handle(WebpMetadataReader.readMetadata(stream));
} catch (RiffProcessingException | MetadataException e) {
throw new TikaException("Can't process Riff data", e);
}
diff --git
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-image-module/src/main/java/org/apache/tika/parser/image/ImageXmp.java
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-image-module/src/main/java/org/apache/tika/parser/image/ImageXmp.java
index af88c13659..8f540148de 100644
---
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-image-module/src/main/java/org/apache/tika/parser/image/ImageXmp.java
+++
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-image-module/src/main/java/org/apache/tika/parser/image/ImageXmp.java
@@ -17,8 +17,6 @@
package org.apache.tika.parser.image;
import java.io.BufferedInputStream;
-import java.io.File;
-import java.io.FileInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
@@ -28,6 +26,7 @@ import com.drew.imaging.jpeg.JpegProcessingException;
import com.drew.imaging.jpeg.JpegSegmentData;
import com.drew.imaging.jpeg.JpegSegmentReader;
import com.drew.imaging.jpeg.JpegSegmentType;
+import com.drew.lang.StreamReader;
import org.apache.commons.io.IOUtils;
import org.apache.commons.io.output.UnsynchronizedByteArrayOutputStream;
import org.xml.sax.SAXException;
@@ -81,12 +80,12 @@ final class ImageXmp {
}
}
- /** JPEG: read APP1 segments, reassemble Extended XMP, parse each
resulting packet. */
- static void extractJpeg(File file, Metadata metadata, ParseContext
context) {
+ /** JPEG: read APP1 segments, reassemble Extended XMP, parse each
resulting packet. Consumes the stream. */
+ static void extractJpeg(InputStream stream, Metadata metadata,
ParseContext context) {
try {
Iterable<byte[]> app1;
try {
- JpegSegmentData data = JpegSegmentReader.readSegments(file,
+ JpegSegmentData data = JpegSegmentReader.readSegments(new
StreamReader(stream),
Collections.singletonList(JpegSegmentType.APP1));
app1 = data.getSegments(JpegSegmentType.APP1);
} catch (JpegProcessingException e) {
@@ -105,10 +104,10 @@ final class ImageXmp {
}
}
- /** WebP: pull the raw packet out of the RIFF {@code "XMP "} chunk and
parse it. */
- static void extractWebp(File file, Metadata metadata, ParseContext
context) {
+ /** WebP: pull the raw packet out of the RIFF {@code "XMP "} chunk and
parse it. Consumes the stream. */
+ static void extractWebp(InputStream stream, Metadata metadata,
ParseContext context) {
try {
- byte[] xmp = readRiffChunk(file, "XMP ");
+ byte[] xmp = readRiffChunk(stream, "XMP ");
if (xmp != null) {
new XmpExtractor().extract(xmp, metadata, context);
}
@@ -123,28 +122,28 @@ final class ImageXmp {
private static final long MAX_CHUNK = 64L * 1024 * 1024;
/** Return the payload of the first top-level RIFF chunk with the given
FourCC, or null. */
- private static byte[] readRiffChunk(File file, String fourCC) throws
IOException {
- try (InputStream in = new BufferedInputStream(new
FileInputStream(file))) {
- byte[] head = new byte[12];
- if (IOUtils.read(in, head, 0, 12) < 12 || head[0] != 'R' ||
head[1] != 'I' ||
- head[2] != 'F' || head[3] != 'F' || head[8] != 'W' ||
head[9] != 'E' ||
- head[10] != 'B' || head[11] != 'P') {
- return null;
- }
- byte[] ch = new byte[8];
- while (IOUtils.read(in, ch, 0, 8) == 8) {
- long size = (ch[4] & 0xffL) | (ch[5] & 0xffL) << 8 |
- (ch[6] & 0xffL) << 16 | (ch[7] & 0xffL) << 24;
- if (fourCC.equals(new String(ch, 0, 4,
StandardCharsets.US_ASCII))) {
- if (size > MAX_CHUNK) {
- return null; // target chunk too large to allocate
- }
- byte[] data = new byte[(int) size];
- return IOUtils.read(in, data, 0, data.length) ==
data.length ? data : null;
+ private static byte[] readRiffChunk(InputStream stream, String fourCC)
throws IOException {
+ // not closed: the stream is the caller's, who rewinds it for the next
pass
+ InputStream in = new BufferedInputStream(stream);
+ byte[] head = new byte[12];
+ if (IOUtils.read(in, head, 0, 12) < 12 || head[0] != 'R' || head[1] !=
'I' ||
+ head[2] != 'F' || head[3] != 'F' || head[8] != 'W' || head[9]
!= 'E' ||
+ head[10] != 'B' || head[11] != 'P') {
+ return null;
+ }
+ byte[] ch = new byte[8];
+ while (IOUtils.read(in, ch, 0, 8) == 8) {
+ long size = (ch[4] & 0xffL) | (ch[5] & 0xffL) << 8 |
+ (ch[6] & 0xffL) << 16 | (ch[7] & 0xffL) << 24;
+ if (fourCC.equals(new String(ch, 0, 4,
StandardCharsets.US_ASCII))) {
+ if (size > MAX_CHUNK) {
+ return null; // target chunk too large to allocate
}
- // a large foreign chunk before "XMP " must be skipped, not
abort the scan
- IOUtils.skipFully(in, size + (size & 1L)); // RIFF pads
chunks to even length
+ byte[] data = new byte[(int) size];
+ return IOUtils.read(in, data, 0, data.length) == data.length ?
data : null;
}
+ // a large foreign chunk before "XMP " must be skipped, not abort
the scan
+ IOUtils.skipFully(in, size + (size & 1L)); // RIFF pads chunks
to even length
}
return null;
}
diff --git
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-image-module/src/main/java/org/apache/tika/parser/image/JpegParser.java
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-image-module/src/main/java/org/apache/tika/parser/image/JpegParser.java
index a96311857f..bf49883b5a 100644
---
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-image-module/src/main/java/org/apache/tika/parser/image/JpegParser.java
+++
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-image-module/src/main/java/org/apache/tika/parser/image/JpegParser.java
@@ -26,6 +26,7 @@ import org.xml.sax.SAXException;
import org.apache.tika.annotation.TikaComponent;
import org.apache.tika.exception.TikaException;
+import org.apache.tika.io.CacheMemoryBudget;
import org.apache.tika.io.TemporaryResources;
import org.apache.tika.io.TikaInputStream;
import org.apache.tika.metadata.Metadata;
@@ -54,9 +55,12 @@ public class JpegParser extends AbstractImageParser {
TemporaryResources tmp = new TemporaryResources();
try {
TikaInputStream tis = TikaInputStream.get(stream, tmp, metadata);
+ // two sequential passes; rewind rather than spool between them
+ tis.enableRewind(parseContext.get(CacheMemoryBudget.class));
// XMP first so it is canonical; the metadata-extractor handlers
(IPTC/EXIF) fill gaps.
- ImageXmp.extractJpeg(tis.getFile(), metadata, parseContext);
- new ImageMetadataExtractor(metadata).parseJpeg(tis.getFile());
+ ImageXmp.extractJpeg(tis, metadata, parseContext);
+ tis.rewind();
+ new ImageMetadataExtractor(metadata).parseJpeg(tis);
} finally {
tmp.dispose();
}
diff --git
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-image-module/src/main/java/org/apache/tika/parser/image/TiffParser.java
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-image-module/src/main/java/org/apache/tika/parser/image/TiffParser.java
index 65664bff10..4a5cad5f48 100644
---
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-image-module/src/main/java/org/apache/tika/parser/image/TiffParser.java
+++
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-image-module/src/main/java/org/apache/tika/parser/image/TiffParser.java
@@ -26,6 +26,7 @@ import org.xml.sax.SAXException;
import org.apache.tika.annotation.TikaComponent;
import org.apache.tika.exception.TikaException;
+import org.apache.tika.io.CacheMemoryBudget;
import org.apache.tika.io.TemporaryResources;
import org.apache.tika.io.TikaInputStream;
import org.apache.tika.metadata.Metadata;
@@ -54,10 +55,11 @@ public class TiffParser extends AbstractImageParser {
TemporaryResources tmp = new TemporaryResources();
try {
TikaInputStream tis = TikaInputStream.get(stream, tmp, metadata);
- tis.getFile(); // spool so tis is fully re-readable below
+ tis.enableRewind(parseContext.get(CacheMemoryBudget.class));
// XMP first so it is canonical; metadata-extractor (IPTC/EXIF)
fills gaps.
ImageXmp.scanAndExtract(tis, metadata, parseContext);
- new ImageMetadataExtractor(metadata).parseTiff(tis.getFile());
+ tis.rewind();
+ new ImageMetadataExtractor(metadata).parseTiff(tis);
} finally {
tmp.dispose();
}
diff --git
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-image-module/src/main/java/org/apache/tika/parser/image/WebPParser.java
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-image-module/src/main/java/org/apache/tika/parser/image/WebPParser.java
index 1af3b5e5a3..459aa07def 100644
---
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-image-module/src/main/java/org/apache/tika/parser/image/WebPParser.java
+++
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-image-module/src/main/java/org/apache/tika/parser/image/WebPParser.java
@@ -25,6 +25,7 @@ import org.xml.sax.SAXException;
import org.apache.tika.annotation.TikaComponent;
import org.apache.tika.exception.TikaException;
+import org.apache.tika.io.CacheMemoryBudget;
import org.apache.tika.io.TikaInputStream;
import org.apache.tika.metadata.Metadata;
import org.apache.tika.mime.MediaType;
@@ -50,8 +51,10 @@ public class WebPParser implements Parser {
public void parse(TikaInputStream tis, ContentHandler handler, Metadata
metadata,
ParseContext context) throws IOException, SAXException,
TikaException {
// XMP first (canonical), then EXIF/etc. from metadata-extractor as
fallback.
- ImageXmp.extractWebp(tis.getFile(), metadata, context);
- new ImageMetadataExtractor(metadata).parseWebP(tis.getFile());
+ tis.enableRewind(context.get(CacheMemoryBudget.class));
+ ImageXmp.extractWebp(tis, metadata, context);
+ tis.rewind();
+ new ImageMetadataExtractor(metadata).parseWebP(tis);
XHTMLContentHandler xhtml = new XHTMLContentHandler(handler, metadata,
context);
xhtml.startDocument();
diff --git
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-image-module/src/test/java/org/apache/tika/parser/image/ImageParsersNoTempFileTest.java
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-image-module/src/test/java/org/apache/tika/parser/image/ImageParsersNoTempFileTest.java
new file mode 100644
index 0000000000..9f970d28b5
--- /dev/null
+++
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-image-module/src/test/java/org/apache/tika/parser/image/ImageParsersNoTempFileTest.java
@@ -0,0 +1,143 @@
+/*
+ * 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.image;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+
+import java.io.ByteArrayInputStream;
+import java.io.InputStream;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.stream.Stream;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+import org.xml.sax.helpers.DefaultHandler;
+
+import org.apache.tika.TikaTest;
+import org.apache.tika.io.CacheMemoryBudget;
+import org.apache.tika.io.TemporaryResources;
+import org.apache.tika.io.TikaInputStream;
+import org.apache.tika.metadata.HttpHeaders;
+import org.apache.tika.metadata.Metadata;
+import org.apache.tika.metadata.TIFF;
+import org.apache.tika.parser.ParseContext;
+import org.apache.tika.parser.Parser;
+
+/**
+ * Embedded images usually arrive already in memory; the image parsers must
not spool them to
+ * disk just to read metadata. The file-system tags are the visible
consequence: they describe
+ * whatever file was read, so in-memory input must not carry them and real
files must.
+ */
+public class ImageParsersNoTempFileTest extends TikaTest {
+
+ private static final String FILE_NAME_KEY =
ImageMetadataExtractor.UNKNOWN_IMG_NS + "File Name";
+ private static final String FILE_SIZE_KEY =
ImageMetadataExtractor.UNKNOWN_IMG_NS + "File Size";
+ private static final String FILE_MODIFIED_KEY =
+ ImageMetadataExtractor.UNKNOWN_IMG_NS + "File Modified Date";
+
+ @TempDir
+ Path tempDir;
+
+ private static final String JPEG = "/test-documents/testJPEG_EXIF.jpg";
+ private static final String TIFF_RES = "/test-documents/testTIFF.tif";
+ private static final String WEBP =
"/test-documents/testWebp_Alpha_Lossless.webp";
+ private static final String WEBP_WIDTH =
ImageMetadataExtractor.UNKNOWN_IMG_NS + "Image Width";
+
+ private static ParseContext context() {
+ ParseContext context = new ParseContext();
+ context.set(CacheMemoryBudget.class, new CacheMemoryBudget(64L * 1024
* 1024));
+ return context;
+ }
+
+ private byte[] bytes(String resource) throws Exception {
+ try (InputStream is = getResourceAsStream(resource)) {
+ return is.readAllBytes();
+ }
+ }
+
+ @Test
+ public void testJpegInMemory() throws Exception {
+ assertNotSpooled(new JpegParser(), JPEG, TIFF.IMAGE_WIDTH.getName());
+ }
+
+ @Test
+ public void testTiffInMemory() throws Exception {
+ assertNotSpooled(new TiffParser(), TIFF_RES,
TIFF.IMAGE_WIDTH.getName());
+ }
+
+ @Test
+ public void testWebPInMemory() throws Exception {
+ assertNotSpooled(new WebPParser(), WEBP, WEBP_WIDTH);
+ }
+
+ @Test
+ public void testJpegFromFile() throws Exception {
+ assertKeepsFileTags(new JpegParser(), JPEG,
TIFF.IMAGE_WIDTH.getName(), "jpg");
+ }
+
+ @Test
+ public void testTiffFromFile() throws Exception {
+ assertKeepsFileTags(new TiffParser(), TIFF_RES,
TIFF.IMAGE_WIDTH.getName(), "tif");
+ }
+
+ @Test
+ public void testWebPFromFile() throws Exception {
+ assertKeepsFileTags(new WebPParser(), WEBP, WEBP_WIDTH, "webp");
+ }
+
+ private void assertNotSpooled(Parser parser, String resource, String
widthKey)
+ throws Exception {
+ byte[] bytes = bytes(resource);
+ Metadata metadata = new Metadata();
+ if (resource.endsWith(".png")) {
+ metadata.set(HttpHeaders.CONTENT_TYPE, "image/png");
+ }
+ try (TemporaryResources tmp = new TemporaryResources()) {
+ tmp.setTemporaryFileDirectory(tempDir);
+ // a stream-backed, non-file TikaInputStream whose only spill
target is tempDir
+ TikaInputStream tis = TikaInputStream.get(new
ByteArrayInputStream(bytes), tmp, metadata);
+ parser.parse(tis, new DefaultHandler(), metadata, context());
+ // temp files live until tmp closes, so any spool would be visible
right here
+ try (Stream<Path> files = Files.list(tempDir)) {
+ assertEquals(0, files.filter(Files::isRegularFile).count(),
+ "parser spooled an in-memory image to disk");
+ }
+ }
+ assertNotNull(metadata.get(widthKey), "metadata was extracted");
+ assertNull(metadata.get(FILE_NAME_KEY), "in-memory input must not
carry file tags");
+ assertNull(metadata.get(FILE_SIZE_KEY), "in-memory input must not
carry file tags");
+ assertNull(metadata.get(FILE_MODIFIED_KEY), "in-memory input must not
carry file tags");
+ }
+
+ private void assertKeepsFileTags(Parser parser, String resource, String
widthKey, String ext)
+ throws Exception {
+ byte[] bytes = bytes(resource);
+ Path image = tempDir.resolve("image." + ext);
+ Files.write(image, bytes);
+ Metadata metadata = new Metadata();
+ try (TikaInputStream tis = TikaInputStream.get(image, metadata)) {
+ parser.parse(tis, new DefaultHandler(), metadata, context());
+ }
+ assertNotNull(metadata.get(widthKey), "metadata was extracted");
+ assertEquals(image.getFileName().toString(),
metadata.get(FILE_NAME_KEY),
+ "a real file keeps metadata-extractor's file-system tags");
+ assertNotNull(metadata.get(FILE_SIZE_KEY), "a real file keeps its size
tag");
+ }
+}
diff --git
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/detect/microsoft/POIFSContainerDetector.java
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/detect/microsoft/POIFSContainerDetector.java
index 356dcba2f1..cb046e3780 100644
---
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/detect/microsoft/POIFSContainerDetector.java
+++
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/detect/microsoft/POIFSContainerDetector.java
@@ -22,6 +22,9 @@ import static org.apache.tika.mime.MediaType.image;
import java.io.IOException;
import java.io.InputStream;
+import java.nio.ByteBuffer;
+import java.nio.channels.Channels;
+import java.nio.channels.SeekableByteChannel;
import java.nio.charset.StandardCharsets;
import java.nio.file.Path;
import java.util.Collections;
@@ -32,18 +35,23 @@ import java.util.Set;
import java.util.regex.Pattern;
import org.apache.commons.io.IOUtils;
+import org.apache.commons.io.input.UnsynchronizedByteArrayInputStream;
import org.apache.poi.hssf.model.InternalWorkbook;
+import org.apache.poi.poifs.common.POIFSConstants;
import org.apache.poi.poifs.filesystem.DirectoryEntry;
import org.apache.poi.poifs.filesystem.DirectoryNode;
import org.apache.poi.poifs.filesystem.DocumentInputStream;
import org.apache.poi.poifs.filesystem.DocumentNode;
import org.apache.poi.poifs.filesystem.Entry;
import org.apache.poi.poifs.filesystem.POIFSFileSystem;
+import org.apache.poi.poifs.storage.BATBlock;
+import org.apache.poi.poifs.storage.HeaderBlock;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.apache.tika.annotation.TikaComponent;
import org.apache.tika.detect.Detector;
+import org.apache.tika.io.CacheMemoryBudget;
import org.apache.tika.io.TikaInputStream;
import org.apache.tika.metadata.Metadata;
import org.apache.tika.mime.MediaType;
@@ -554,9 +562,15 @@ public class POIFSContainerDetector implements Detector {
}
- private Set<String> getTopLevelNames(TikaInputStream stream) throws
IOException {
- // Force the document stream to a (possibly temporary) file
- // so we don't modify the current position of the stream.
+ private Set<String> getTopLevelNames(TikaInputStream stream, ParseContext
context)
+ throws IOException {
+ if (!stream.hasFile()) {
+ Set<String> names = getTopLevelNamesInMemory(stream, context);
+ if (names != null) {
+ return names;
+ }
+ }
+ // random access over content that is not in memory: use the file
Path file = stream.getPath();
if (file == null) {
@@ -583,16 +597,135 @@ public class POIFSContainerDetector implements Detector {
}
}
+ /**
+ * Opens the container from memory. POI's stream loader copies the object
into its own
+ * heap array, sized from the header rather than the content, and keeps it
for the
+ * container's lifetime -- so that allocation is reserved from the
CacheMemoryBudget
+ * before it is made. Returns null, and the caller uses the file, when the
content is not
+ * in memory, the budget has no room for the copy, or POI's stream loader
rejects the
+ * object (it is stricter than the file loader on truncated objects).
+ * <p>
+ * With no budget in the context there is no accounting at all, and a
byte[]-backed
+ * stream has no spill threshold to bound it either, so the copy is capped
outright:
+ * above {@link #MAX_UNBUDGETED_COPY} the file path -- which is what 4.0.0
always did --
+ * is used instead.
+ */
+ /**
+ * Ceiling on POI's in-memory copy when no CacheMemoryBudget is present to
account for it.
+ * Embedded OLE2 objects are typically a few hundred KB; past this the
file loader's lazy
+ * block reads cost less than the heap.
+ */
+ private static final long MAX_UNBUDGETED_COPY = 16L * 1024 * 1024;
+
+ private Set<String> getTopLevelNamesInMemory(TikaInputStream stream,
ParseContext context)
+ throws IOException {
+ CacheMemoryBudget budget = context == null ? null :
context.get(CacheMemoryBudget.class);
+ // the budget decides how much the drain below keeps in memory
+ stream.enableRewind(budget);
+ POIFSFileSystem fs = null;
+ long reserved = 0;
+ try (SeekableByteChannel channel = stream.getSeekableByteChannel()) {
+ if (TikaInputStream.inMemoryContent(channel) == null) {
+ return null;
+ }
+ // POI allocates what the header declares. The bytes in hand are
the truth: a
+ // header that declares more than they can account for is lying,
and the file
+ // loader (block by block, no such allocation) is the only safe
way to read it.
+ long copy = honestDeclaredSize(channel);
+ if (copy < 0) {
+ return null;
+ }
+ if (budget == null) {
+ if (copy > MAX_UNBUDGETED_COPY) {
+ return null;
+ }
+ } else {
+ if (budget.tryReserve(copy) == 0) {
+ return null;
+ }
+ reserved = copy;
+ }
+ // POI reads from wherever the channel sits; do not rely on it
being fresh
+ channel.position(0);
+ fs = new POIFSFileSystem(Channels.newInputStream(channel));
+ Set<String> names = getTopLevelNames(fs.getRoot());
+ stream.setOpenContainer(fs);
+ fs = null; // published: the stream owns it now, the finally
must not close it
+ if (reserved > 0) {
+ long charged = reserved;
+ stream.addCloseableResource(() -> budget.release(charged));
+ reserved = 0;
+ }
+ return names;
+ } catch (SecurityException e) {
+ throw e;
+ } catch (IOException | RuntimeException e) {
+ return null;
+ } finally {
+ if (reserved > 0) {
+ budget.release(reserved);
+ }
+ if (fs != null) {
+ closeQuietly(fs);
+ }
+ }
+ }
+
+ /**
+ * The heap POI's stream loader would allocate for this object -- sized
from the header's
+ * declared BAT count, not the content -- or -1 when the header cannot be
read or declares
+ * more than the content can account for. A valid header covers at most
one BAT block of
+ * unused entries beyond the actual size; anything past that is a
malformed or hostile
+ * header (a 512-byte object can declare hundreds of MB) and must not be
opened from a
+ * stream at all.
+ */
+ static long honestDeclaredSize(SeekableByteChannel channel) throws
IOException {
+ byte[] header = new byte[POIFSConstants.SMALLER_BIG_BLOCK_SIZE];
+ long start = channel.position();
+ try {
+ channel.position(0);
+ ByteBuffer buffer = ByteBuffer.wrap(header);
+ while (buffer.hasRemaining() && channel.read(buffer) > 0) {
+ // fill the header block
+ }
+ if (buffer.hasRemaining()) {
+ return -1;
+ }
+ } finally {
+ channel.position(start);
+ }
+ try {
+ HeaderBlock hb = new HeaderBlock(
+
UnsynchronizedByteArrayInputStream.builder().setByteArray(header).get());
+ long declared = BATBlock.calculateMaximumSize(hb);
+ long oneBatSpan = (long) hb.getBigBlockSize().getBigBlockSize() *
+ hb.getBigBlockSize().getBATEntriesPerBlock();
+ long actual = channel.size();
+ return declared > actual + oneBatSpan ? -1 : declared;
+ } catch (IOException | RuntimeException e) {
+ return -1;
+ }
+ }
+
+ private static void closeQuietly(POIFSFileSystem fs) {
+ try {
+ fs.close();
+ } catch (IOException e) {
+ LOG.debug("failed to close abandoned POIFSFileSystem", e);
+ }
+ }
+
public MediaType detect(TikaInputStream tis, Metadata metadata,
ParseContext parseContext) throws IOException {
// Check if we have access to the document
if (tis == null) {
return MediaType.OCTET_STREAM;
}
- return handleTikaStream(tis, metadata);
+ return handleTikaStream(tis, metadata, parseContext);
}
- private MediaType handleTikaStream(TikaInputStream tis, Metadata metadata)
throws IOException {
+ private MediaType handleTikaStream(TikaInputStream tis, Metadata metadata,
ParseContext context)
+ throws IOException {
//try for an open container
Set<String> names = tryOpenContainerOnTikaInputStream(tis, metadata);
@@ -601,10 +734,8 @@ public class POIFSContainerDetector implements Detector {
return OCTET_STREAM;
}
- // If OLE, spool to disk
if (names == null) {
- // spool to disk and try detection
- names = getTopLevelNames(tis);
+ names = getTopLevelNames(tis, context);
}
// Detect based on the names (as available)
diff --git
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/test/java/org/apache/tika/detect/microsoft/POIFSContainerDetectorNoTempFileTest.java
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/test/java/org/apache/tika/detect/microsoft/POIFSContainerDetectorNoTempFileTest.java
new file mode 100644
index 0000000000..6a06ded324
--- /dev/null
+++
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/test/java/org/apache/tika/detect/microsoft/POIFSContainerDetectorNoTempFileTest.java
@@ -0,0 +1,116 @@
+/*
+ * 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.detect.microsoft;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.io.ByteArrayInputStream;
+import java.io.InputStream;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.stream.Stream;
+
+import org.apache.poi.poifs.filesystem.POIFSFileSystem;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import org.apache.tika.TikaTest;
+import org.apache.tika.io.CacheMemoryBudget;
+import org.apache.tika.io.TemporaryResources;
+import org.apache.tika.io.TikaInputStream;
+import org.apache.tika.metadata.Metadata;
+import org.apache.tika.mime.MediaType;
+import org.apache.tika.parser.ParseContext;
+
+/**
+ * Detection used to spool every in-memory OLE2 object to a temp file to read
its
+ * top-level entry names; it must now open the container from memory.
+ */
+public class POIFSContainerDetectorNoTempFileTest extends TikaTest {
+
+ @TempDir
+ Path tempDir;
+
+ @Test
+ public void testNoTempFileForInMemoryInput() throws Exception {
+ byte[] bytes;
+ try (InputStream is =
getResourceAsStream("/test-documents/testWORD.doc")) {
+ bytes = is.readAllBytes();
+ }
+ ParseContext context = new ParseContext();
+ context.set(CacheMemoryBudget.class, new CacheMemoryBudget(64L * 1024
* 1024));
+ Metadata metadata = new Metadata();
+ try (TemporaryResources tmp = new TemporaryResources()) {
+ tmp.setTemporaryFileDirectory(tempDir);
+ TikaInputStream tis = TikaInputStream.get(new
ByteArrayInputStream(bytes), tmp, metadata);
+ MediaType type = new POIFSContainerDetector().detect(tis,
metadata, context);
+ assertEquals(MediaType.application("msword"), type);
+ try (Stream<Path> files = Files.list(tempDir)) {
+ assertEquals(0, files.count(), "detector spooled an in-memory
OLE2 object to disk");
+ }
+ assertTrue(tis.getOpenContainer() instanceof POIFSFileSystem,
"open container kept for the parser");
+ assertEquals(0, tis.getPosition(), "detection must not move the
stream");
+ assertEquals(0xd0, tis.read(), "stream still readable from the
start");
+ }
+ }
+
+ /** POI's copy is charged to the budget for the container's lifetime, then
released. */
+ @Test
+ public void testInMemoryCopyIsChargedAndReleased() throws Exception {
+ byte[] bytes;
+ try (InputStream is =
getResourceAsStream("/test-documents/testWORD.doc")) {
+ bytes = is.readAllBytes();
+ }
+ CacheMemoryBudget budget = new CacheMemoryBudget(64L * 1024 * 1024);
+ ParseContext context = new ParseContext();
+ context.set(CacheMemoryBudget.class, budget);
+ Metadata metadata = new Metadata();
+ TikaInputStream tis;
+ try (TemporaryResources tmp = new TemporaryResources()) {
+ tis = TikaInputStream.get(new ByteArrayInputStream(bytes), tmp,
metadata);
+ assertEquals(MediaType.application("msword"),
+ new POIFSContainerDetector().detect(tis, metadata,
context));
+ assertTrue(tis.getOpenContainer() instanceof POIFSFileSystem);
+ assertTrue(budget.getReservedBytes() >= bytes.length,
+ "POI's header-sized copy is charged while the container is
open");
+ }
+ assertEquals(0, budget.getReservedBytes(), "released with the stream");
+ }
+
+ /** No room in the budget for POI's copy: detection still succeeds, from
the file. */
+ @Test
+ public void testFallsBackToFileWhenBudgetRefusesTheCopy() throws Exception
{
+ byte[] bytes;
+ try (InputStream is =
getResourceAsStream("/test-documents/testWORD.doc")) {
+ bytes = is.readAllBytes();
+ }
+ CacheMemoryBudget budget = new CacheMemoryBudget(4096);
+ ParseContext context = new ParseContext();
+ context.set(CacheMemoryBudget.class, budget);
+ Metadata metadata = new Metadata();
+ try (TemporaryResources tmp = new TemporaryResources()) {
+ tmp.setTemporaryFileDirectory(tempDir);
+ TikaInputStream tis = TikaInputStream.get(new
ByteArrayInputStream(bytes), tmp, metadata);
+ assertEquals(MediaType.application("msword"),
+ new POIFSContainerDetector().detect(tis, metadata,
context));
+ assertTrue(tis.getOpenContainer() instanceof POIFSFileSystem);
+ assertTrue(tis.hasFile(), "opened from the file instead");
+ }
+ assertEquals(0, budget.getReservedBytes(), "nothing left charged");
+ }
+}
diff --git
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/test/java/org/apache/tika/detect/microsoft/POIFSDeclaredSizeTest.java
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/test/java/org/apache/tika/detect/microsoft/POIFSDeclaredSizeTest.java
new file mode 100644
index 0000000000..8450153bec
--- /dev/null
+++
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/test/java/org/apache/tika/detect/microsoft/POIFSDeclaredSizeTest.java
@@ -0,0 +1,154 @@
+/*
+ * 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.detect.microsoft;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.io.ByteArrayInputStream;
+import java.io.InputStream;
+import java.nio.ByteBuffer;
+import java.nio.ByteOrder;
+import java.nio.channels.SeekableByteChannel;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.StandardOpenOption;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import org.apache.tika.TikaTest;
+import org.apache.tika.io.CacheMemoryBudget;
+import org.apache.tika.io.TemporaryResources;
+import org.apache.tika.io.TikaInputStream;
+import org.apache.tika.metadata.Metadata;
+import org.apache.tika.parser.ParseContext;
+
+/**
+ * POI sizes its in-memory OLE2 buffer from the header's declared BAT count
rather than the
+ * actual length, so a 512-byte object can demand hundreds of MB. The
in-memory detection path
+ * only believes a header the bytes in hand can account for, and reserves that
from the
+ * budget before POI allocates it.
+ */
+public class POIFSDeclaredSizeTest extends TikaTest {
+
+ private static final int BAT_COUNT_OFFSET = 0x2C;
+ private static final int SECTOR_SHIFT_OFFSET = 0x1E;
+
+ @TempDir
+ Path tempDir;
+
+ /** A bare 512-byte OLE2 header declaring {@code batCount} BAT blocks and
nothing else. */
+ private static byte[] header(int batCount) {
+ byte[] data = new byte[512];
+ byte[] magic = {(byte) 0xd0, (byte) 0xcf, 0x11, (byte) 0xe0,
+ (byte) 0xa1, (byte) 0xb1, 0x1a, (byte) 0xe1};
+ System.arraycopy(magic, 0, data, 0, magic.length);
+ data[SECTOR_SHIFT_OFFSET] = 9; // 2^9 = 512-byte blocks
+
ByteBuffer.wrap(data).order(ByteOrder.LITTLE_ENDIAN).putInt(BAT_COUNT_OFFSET,
batCount);
+ return data;
+ }
+
+ private SeekableByteChannel channelFor(byte[] bytes, String name) throws
Exception {
+ Path p = tempDir.resolve(name);
+ Files.write(p, bytes);
+ return Files.newByteChannel(p, StandardOpenOption.READ);
+ }
+
+ @Test
+ public void testLyingHeaderIsRejected() throws Exception {
+ // (1 + 3813 * 128) * 512 == 249_889_280, just under POI's 250MB
allocation ceiling,
+ // declared by 512 bytes of content
+ try (SeekableByteChannel channel = channelFor(header(3813),
"hostile.ole")) {
+ assertEquals(-1,
POIFSContainerDetector.honestDeclaredSize(channel),
+ "the content cannot account for what the header declares");
+ assertEquals(0, channel.position(), "the size probe must not move
the channel");
+ }
+ }
+
+ @Test
+ public void testHonestHeaderIsBelievedWithinOneBatBlock() throws Exception
{
+ // header only, declaring one BAT block: 129 sectors, 512 bytes
present -- within slack
+ try (SeekableByteChannel channel = channelFor(header(1),
"modest.ole")) {
+ assertEquals((1 + 128) * 512L,
POIFSContainerDetector.honestDeclaredSize(channel));
+ }
+ // two BAT blocks declared by 512 bytes: one block past what the
content covers
+ try (SeekableByteChannel channel = channelFor(header(2),
"twoblocks.ole")) {
+ assertEquals(-1,
POIFSContainerDetector.honestDeclaredSize(channel));
+ }
+ }
+
+ @Test
+ public void testRealDocumentHeaderIsHonest() throws Exception {
+ byte[] bytes;
+ try (InputStream is =
getResourceAsStream("/test-documents/testWORD.doc")) {
+ bytes = is.readAllBytes();
+ }
+ try (SeekableByteChannel channel = channelFor(bytes, "real.doc")) {
+ long declared = POIFSContainerDetector.honestDeclaredSize(channel);
+ assertTrue(declared >= bytes.length && declared <= bytes.length +
128 * 512L,
+ "a real header declares about its own size: " + declared +
" vs " + bytes.length);
+ }
+ }
+
+ @Test
+ public void testTooShortForAHeaderIsRejected() throws Exception {
+ try (SeekableByteChannel channel = channelFor(new byte[16],
"short.bin")) {
+ assertEquals(-1,
POIFSContainerDetector.honestDeclaredSize(channel));
+ }
+ }
+
+ /**
+ * End to end. NOTE: this asserts only that the crafted object does not
become an open
+ * container and leaves nothing charged -- both of which also hold if the
declared-size
+ * guard is deleted, because POI throws on the truncated read either way.
The guard's
+ * real effect is the ~238MB POI would allocate first, and measuring that
needs
+ * com.sun.management, which forbidden-apis bans. The guard's arithmetic
and its input
+ * channel type are pinned by the unit tests above instead.
+ */
+ @Test
+ public void testHostileHeaderDoesNotBecomeAnOpenContainer() throws
Exception {
+ ParseContext context = new ParseContext();
+ CacheMemoryBudget budget = new CacheMemoryBudget(1024L * 1024 * 1024);
+ context.set(CacheMemoryBudget.class, budget);
+ Metadata metadata = new Metadata();
+ try (TemporaryResources tmp = new TemporaryResources()) {
+ TikaInputStream tis = TikaInputStream.get(
+ new ByteArrayInputStream(header(3813)), tmp, metadata);
+ new POIFSContainerDetector().detect(tis, metadata, context);
+ assertNull(tis.getOpenContainer(),
+ "a header-only object must not be opened from memory");
+ }
+ assertEquals(0, budget.getReservedBytes(), "nothing left charged");
+ }
+
+ /** The channel type production actually uses is in-memory, not a file. */
+ @Test
+ public void testLyingHeaderIsRejectedOverAnInMemoryChannel() throws
Exception {
+ try (TemporaryResources tmp = new TemporaryResources()) {
+ TikaInputStream tis = TikaInputStream.get(new
ByteArrayInputStream(header(3813)), tmp,
+ new Metadata());
+ tis.enableRewind(null);
+ try (SeekableByteChannel channel = tis.getSeekableByteChannel()) {
+ assertNotNull(TikaInputStream.inMemoryContent(channel),
"precondition: in memory");
+ assertEquals(-1,
POIFSContainerDetector.honestDeclaredSize(channel));
+ }
+ }
+ }
+}
diff --git
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-miscoffice-module/src/main/java/org/apache/tika/parser/odf/OpenDocumentParser.java
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-miscoffice-module/src/main/java/org/apache/tika/parser/odf/OpenDocumentParser.java
index c500689ab4..5cc0f37a1c 100644
---
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-miscoffice-module/src/main/java/org/apache/tika/parser/odf/OpenDocumentParser.java
+++
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-miscoffice-module/src/main/java/org/apache/tika/parser/odf/OpenDocumentParser.java
@@ -45,6 +45,7 @@ import org.apache.tika.exception.EncryptedDocumentException;
import org.apache.tika.exception.TikaException;
import org.apache.tika.extractor.EmbeddedDocumentExtractor;
import org.apache.tika.extractor.EmbeddedDocumentUtil;
+import org.apache.tika.io.CacheMemoryBudget;
import org.apache.tika.io.TikaInputStream;
import org.apache.tika.metadata.HttpHeaders;
import org.apache.tika.metadata.Metadata;
@@ -320,14 +321,13 @@ public class OpenDocumentParser implements Parser {
if (embeddedName.contains("Pictures/")) {
embeddedMetadata.set(TikaCoreProperties.EMBEDDED_RESOURCE_TYPE,
TikaCoreProperties.EmbeddedResourceType.INLINE.toString());
- //spool
- tisZip.getFile();
+
tisZip.enableRewind(context.get(CacheMemoryBudget.class));
MediaType embeddedMimeType =
EmbeddedDocumentUtil.getDetector(context)
.detect(tisZip, embeddedMetadata, context);
if (embeddedMimeType != null) {
embeddedMetadata.set(HttpHeaders.CONTENT_TYPE,
embeddedMimeType.toString());
}
- tisZip.reset();
+ tisZip.rewind();
// Tag the picture with the draw:page indices it
// appears on (set populated by scanPicturePages).
// A null lookup means "not referenced by any
diff --git
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-miscoffice-module/src/test/java/org/apache/tika/parser/odf/OpenDocumentParserNoTempFileTest.java
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-miscoffice-module/src/test/java/org/apache/tika/parser/odf/OpenDocumentParserNoTempFileTest.java
new file mode 100644
index 0000000000..fd93ebb635
--- /dev/null
+++
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-miscoffice-module/src/test/java/org/apache/tika/parser/odf/OpenDocumentParserNoTempFileTest.java
@@ -0,0 +1,104 @@
+/*
+ * 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.odf;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.io.ByteArrayInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.ArrayList;
+import java.util.List;
+
+import org.junit.jupiter.api.Test;
+
+import org.apache.tika.TikaTest;
+import org.apache.tika.detect.DefaultDetector;
+import org.apache.tika.detect.Detector;
+import org.apache.tika.io.CacheMemoryBudget;
+import org.apache.tika.io.TemporaryResources;
+import org.apache.tika.io.TikaInputStream;
+import org.apache.tika.metadata.Metadata;
+import org.apache.tika.metadata.TikaCoreProperties;
+import org.apache.tika.mime.MediaType;
+import org.apache.tika.parser.ParseContext;
+import org.apache.tika.sax.BodyContentHandler;
+
+/**
+ * Inline pictures were spooled to a temp file before detection; with rewind
support the
+ * entry stays in memory. Watching the temp directory cannot see this -- each
entry gets its
+ * own TemporaryResources -- so the assertion is on the stream the detector is
handed.
+ */
+public class OpenDocumentParserNoTempFileTest extends TikaTest {
+
+ /** Records whether each inline picture reached detection backed by a
file. */
+ private static class SpyDetector implements Detector {
+ private final Detector delegate = new DefaultDetector();
+ private final List<Boolean> pictureHadFile = new ArrayList<>();
+
+ @Override
+ public MediaType detect(TikaInputStream input, Metadata metadata,
ParseContext context)
+ throws IOException {
+ String type =
metadata.get(TikaCoreProperties.EMBEDDED_RESOURCE_TYPE);
+ if
(TikaCoreProperties.EmbeddedResourceType.INLINE.toString().equals(type)) {
+ pictureHadFile.add(input.hasFile());
+ }
+ return delegate.detect(input, metadata, context);
+ }
+ }
+
+ @Test
+ public void testInlinePicturesAreDetectedFromMemory() throws Exception {
+ byte[] bytes;
+ try (InputStream is =
getResourceAsStream("/test-documents/testODTEmbedded.odt")) {
+ bytes = is.readAllBytes();
+ }
+ SpyDetector spy = new SpyDetector();
+ ParseContext context = new ParseContext();
+ context.set(Detector.class, spy);
+ context.set(CacheMemoryBudget.class, new CacheMemoryBudget(64L * 1024
* 1024));
+ Metadata metadata = new Metadata();
+ BodyContentHandler handler = new BodyContentHandler();
+ try (TemporaryResources tmp = new TemporaryResources()) {
+ TikaInputStream tis = TikaInputStream.get(new
ByteArrayInputStream(bytes), tmp, metadata);
+ new OpenDocumentParser().parse(tis, handler, metadata, context);
+ }
+ assertFalse(spy.pictureHadFile.isEmpty(), "no inline picture reached
detection");
+ for (Boolean hadFile : spy.pictureHadFile) {
+ assertFalse(hadFile, "inline picture was spooled to disk before
detection");
+ }
+ assertTrue(handler.toString().length() > 0, "content was extracted");
+ }
+
+ /** The picture must be re-readable after detection, or the embedded parse
sees nothing. */
+ @Test
+ public void testEmbeddedPicturesStillParseAfterRewind() throws Exception {
+ List<Metadata> metadataList =
getRecursiveMetadata("testODTEmbedded.odt");
+ boolean sawImage = false;
+ for (Metadata m : metadataList) {
+ assertEquals(null, m.get(TikaCoreProperties.EMBEDDED_EXCEPTION),
+ "embedded exception for " +
m.get(TikaCoreProperties.RESOURCE_NAME_KEY));
+ String type = m.get(TikaCoreProperties.EMBEDDED_RESOURCE_TYPE);
+ if
(TikaCoreProperties.EmbeddedResourceType.INLINE.toString().equals(type)) {
+ sawImage = true;
+ }
+ }
+ assertTrue(sawImage, "no inline picture in the recursive metadata");
+ }
+}
diff --git
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-pdf-module/src/main/java/org/apache/tika/parser/pdf/PDFParser.java
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-pdf-module/src/main/java/org/apache/tika/parser/pdf/PDFParser.java
index c61f33cc45..9267506822 100644
---
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-pdf-module/src/main/java/org/apache/tika/parser/pdf/PDFParser.java
+++
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-pdf-module/src/main/java/org/apache/tika/parser/pdf/PDFParser.java
@@ -40,7 +40,6 @@ import org.apache.pdfbox.cos.COSString;
import org.apache.pdfbox.io.MemoryUsageSetting;
import org.apache.pdfbox.io.RandomAccessRead;
import org.apache.pdfbox.io.RandomAccessReadBuffer;
-import org.apache.pdfbox.io.RandomAccessReadBufferedFile;
import org.apache.pdfbox.io.RandomAccessStreamCache;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDDocumentInformation;
@@ -66,6 +65,7 @@ import org.apache.tika.exception.EncryptedDocumentException;
import org.apache.tika.exception.TikaException;
import org.apache.tika.extractor.EmbeddedDocumentExtractor;
import org.apache.tika.extractor.EmbeddedDocumentUtil;
+import org.apache.tika.io.CacheMemoryBudget;
import org.apache.tika.io.TikaInputStream;
import org.apache.tika.metadata.AccessPermissions;
import org.apache.tika.metadata.HttpHeaders;
@@ -181,6 +181,8 @@ public class PDFParser implements Parser, RenderingParser {
context.set(OCRPageCounter.class, new OCRPageCounter());
try {
if (shouldSpool(localConfig)) {
+ // later stages re-open the document (xref scan, renderer,
per-page renders)
+ tis.enableRewind(context.get(CacheMemoryBudget.class));
context.set(PDFRenderingState.class, new
PDFRenderingState(tis));
}
@@ -313,10 +315,7 @@ public class PDFParser implements Parser, RenderingParser {
return;
}
List<StartXRefOffset> xRefOffsets = new ArrayList<>();
- //TODO -- can we use the PDFBox parser's RandomAccessRead
- //so that we don't have to reopen from file?
- try (RandomAccessRead ra =
- new
RandomAccessReadBufferedFile(tikaInputStream.getFile())) {
+ try (RandomAccessRead ra = PDFRandomAccess.open(tikaInputStream,
parseContext)) {
StartXRefScanner xRefScanner = new StartXRefScanner(ra);
xRefOffsets.addAll(xRefScanner.scan());
} catch (IOException e) {
@@ -495,21 +494,22 @@ public class PDFParser implements Parser, RenderingParser
{
ParseContext context)
throws IOException, EncryptedDocumentException {
try {
- PDDocument pdDocument = null;
if (tis.hasFile()) {
// File based -- send file directly to PDFBox
- pdDocument =
- getPDDocument(tis.getPath(), password,
streamCacheCreateFunction, metadata, context);
- } else {
- tis.setCloseShield();
+ return getPDDocument(tis.getPath(), password,
streamCacheCreateFunction, metadata, context);
+ }
+ // PDFBox owns the reader and closes it with the document
+ RandomAccessRead ra = PDFRandomAccess.open(tis, context);
+ try {
+ return getPDDocument(ra, password, streamCacheCreateFunction,
metadata, context);
+ } catch (IOException | RuntimeException e) {
try {
- pdDocument = getPDDocumentFromStream(tis, password,
- streamCacheCreateFunction, metadata, context);
- } finally {
- tis.removeCloseShield();
+ ra.close();
+ } catch (IOException closeFailure) {
+ e.addSuppressed(closeFailure);
}
+ throw e;
}
- return pdDocument;
} catch (IOException e) {
if (e.getMessage() != null &&
e.getMessage().contains("No security handler for filter"))
{
@@ -519,6 +519,12 @@ public class PDFParser implements Parser, RenderingParser {
}
}
+ /**
+ * @deprecated no longer called by this parser: stream-backed input is
loaded through
+ * {@link #getPDDocument(RandomAccessRead, String,
RandomAccessStreamCache.StreamCacheCreateFunction, Metadata, ParseContext)},
+ * which does not copy the document into heap the way {@code
RandomAccessReadBuffer(InputStream)} does
+ */
+ @Deprecated
protected PDDocument getPDDocumentFromStream(InputStream inputStream,
String password,
RandomAccessStreamCache.StreamCacheCreateFunction streamCacheCreateFunction,
Metadata metadata,
@@ -526,6 +532,13 @@ public class PDFParser implements Parser, RenderingParser {
return Loader.loadPDF(new RandomAccessReadBuffer(inputStream),
password, streamCacheCreateFunction);
}
+ protected PDDocument getPDDocument(RandomAccessRead source, String
password,
+
RandomAccessStreamCache.StreamCacheCreateFunction streamCacheCreateFunction,
+ Metadata metadata,
+ ParseContext parseContext) throws
IOException {
+ return Loader.loadPDF(source, password, streamCacheCreateFunction);
+ }
+
protected PDDocument getPDDocument(Path path, String password,
RandomAccessStreamCache.StreamCacheCreateFunction
streamCacheCreateFunction, Metadata
metadata,
diff --git
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-pdf-module/src/main/java/org/apache/tika/parser/pdf/PDFRandomAccess.java
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-pdf-module/src/main/java/org/apache/tika/parser/pdf/PDFRandomAccess.java
new file mode 100644
index 0000000000..4db4a092f2
--- /dev/null
+++
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-pdf-module/src/main/java/org/apache/tika/parser/pdf/PDFRandomAccess.java
@@ -0,0 +1,79 @@
+/*
+ * 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 java.io.IOException;
+import java.nio.ByteBuffer;
+import java.nio.channels.SeekableByteChannel;
+
+import org.apache.pdfbox.io.RandomAccessRead;
+import org.apache.pdfbox.io.RandomAccessReadBuffer;
+import org.apache.pdfbox.io.RandomAccessReadBufferedFile;
+
+import org.apache.tika.io.CacheMemoryBudget;
+import org.apache.tika.io.TikaInputStream;
+import org.apache.tika.parser.ParseContext;
+
+/**
+ * One way to hand PDFBox a document: in-memory content is read in place
through a zero-copy
+ * view, anything else through PDFBox's buffered file reader. Neither copies
the document, and
+ * every call starts from byte 0 regardless of the stream's position -- so a
renderer can
+ * re-open the same document per page. Closing the returned reader releases
the view.
+ */
+public final class PDFRandomAccess {
+
+ private PDFRandomAccess() {
+ }
+
+ public static RandomAccessRead open(TikaInputStream tis, ParseContext
context)
+ throws IOException {
+ if (tis.hasFile()) {
+ return new RandomAccessReadBufferedFile(tis.getFile());
+ }
+ tis.enableRewind(context == null ? null :
context.get(CacheMemoryBudget.class));
+ SeekableByteChannel channel = tis.getSeekableByteChannel();
+ try {
+ ByteBuffer view = TikaInputStream.inMemoryContent(channel);
+ if (view == null) {
+ // the drain spilled: the content is on disk now
+ channel.close();
+ return new RandomAccessReadBufferedFile(tis.getFile());
+ }
+ return wrap(channel, view);
+ } catch (IOException | RuntimeException e) {
+ try {
+ channel.close();
+ } catch (IOException closeFailure) {
+ e.addSuppressed(closeFailure);
+ }
+ throw e;
+ }
+ }
+
+ private static RandomAccessRead wrap(SeekableByteChannel channel,
ByteBuffer view) {
+ return new RandomAccessReadBuffer(view) {
+ @Override
+ public void close() throws IOException {
+ try {
+ super.close();
+ } finally {
+ channel.close();
+ }
+ }
+ };
+ }
+}
diff --git
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-pdf-module/src/main/java/org/apache/tika/renderer/pdf/pdfbox/PDFBoxRenderer.java
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-pdf-module/src/main/java/org/apache/tika/renderer/pdf/pdfbox/PDFBoxRenderer.java
index 8c5c94592a..7a421d47d2 100644
---
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-pdf-module/src/main/java/org/apache/tika/renderer/pdf/pdfbox/PDFBoxRenderer.java
+++
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-pdf-module/src/main/java/org/apache/tika/renderer/pdf/pdfbox/PDFBoxRenderer.java
@@ -25,7 +25,7 @@ import java.util.Collections;
import java.util.Set;
import org.apache.pdfbox.Loader;
-import org.apache.pdfbox.io.RandomAccessReadBuffer;
+import org.apache.pdfbox.io.RandomAccessRead;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.rendering.ImageType;
import org.apache.pdfbox.rendering.PDFRenderer;
@@ -47,6 +47,7 @@ import org.apache.tika.mime.MediaType;
import org.apache.tika.parser.ParseContext;
import org.apache.tika.parser.pdf.PDFParser;
import org.apache.tika.parser.pdf.PDFParserConfig;
+import org.apache.tika.parser.pdf.PDFRandomAccess;
import org.apache.tika.renderer.PageBasedRenderResults;
import org.apache.tika.renderer.PageRangeRequest;
import org.apache.tika.renderer.RenderRequest;
@@ -100,7 +101,21 @@ public class PDFBoxRenderer implements PDDocumentRenderer {
if (tis.getOpenContainer() != null) {
pdDocument = (PDDocument) tis.getOpenContainer();
} else {
- pdDocument = Loader.loadPDF(new RandomAccessReadBuffer(tis));
+ // a fresh reader from byte 0 each time, so per-page renders do
not depend on
+ // where the stream was left; the document closes it -- but only
once it exists,
+ // so a failed load must close the reader itself or its channel
pin (and the
+ // budget behind it) leaks for the life of the stream
+ RandomAccessRead ra = PDFRandomAccess.open(tis, parseContext);
+ try {
+ pdDocument = Loader.loadPDF(ra);
+ } catch (IOException | RuntimeException e) {
+ try {
+ ra.close();
+ } catch (IOException closeFailure) {
+ e.addSuppressed(closeFailure);
+ }
+ throw e;
+ }
mustClose = true;
}
PageBasedRenderResults results = new PageBasedRenderResults(new
TemporaryResources());
diff --git
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-pdf-module/src/test/java/org/apache/tika/parser/pdf/PDFParserNoTempFileTest.java
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-pdf-module/src/test/java/org/apache/tika/parser/pdf/PDFParserNoTempFileTest.java
new file mode 100644
index 0000000000..9529fee4d4
--- /dev/null
+++
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-pdf-module/src/test/java/org/apache/tika/parser/pdf/PDFParserNoTempFileTest.java
@@ -0,0 +1,71 @@
+/*
+ * 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.assertNotNull;
+
+import java.io.ByteArrayInputStream;
+import java.io.InputStream;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.stream.Stream;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+import org.xml.sax.helpers.DefaultHandler;
+
+import org.apache.tika.TikaTest;
+import org.apache.tika.io.CacheMemoryBudget;
+import org.apache.tika.io.TemporaryResources;
+import org.apache.tika.io.TikaInputStream;
+import org.apache.tika.metadata.Metadata;
+import org.apache.tika.metadata.PDF;
+import org.apache.tika.parser.ParseContext;
+
+/**
+ * The incremental-update xref scan used to spool in-memory PDFs to a file; it
must scan
+ * from memory like the main parse does.
+ */
+public class PDFParserNoTempFileTest extends TikaTest {
+
+ @TempDir
+ Path tempDir;
+
+ @Test
+ public void testXRefScanNoTempFileForInMemoryInput() throws Exception {
+ byte[] bytes;
+ try (InputStream is =
getResourceAsStream("/test-documents/testPDF_incrementalUpdates.pdf")) {
+ bytes = is.readAllBytes();
+ }
+ PDFParserConfig config = new PDFParserConfig();
+ config.setExtractIncrementalUpdateInfo(true);
+ ParseContext context = new ParseContext();
+ context.set(PDFParserConfig.class, config);
+ context.set(CacheMemoryBudget.class, new CacheMemoryBudget(64L * 1024
* 1024));
+ Metadata metadata = new Metadata();
+ try (TemporaryResources tmp = new TemporaryResources()) {
+ tmp.setTemporaryFileDirectory(tempDir);
+ TikaInputStream tis = TikaInputStream.get(new
ByteArrayInputStream(bytes), tmp, metadata);
+ new PDFParser().parse(tis, new DefaultHandler(), metadata,
context);
+ try (Stream<Path> files = Files.list(tempDir)) {
+ assertEquals(0, files.count(), "xref scan spooled an in-memory
PDF to disk");
+ }
+ }
+ assertNotNull(metadata.get(PDF.PDF_INCREMENTAL_UPDATE_COUNT),
"incremental update info was extracted");
+ }
+}
diff --git
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-pdf-module/src/test/java/org/apache/tika/parser/pdf/PDFPerPageRenderTest.java
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-pdf-module/src/test/java/org/apache/tika/parser/pdf/PDFPerPageRenderTest.java
new file mode 100644
index 0000000000..df88be575c
--- /dev/null
+++
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-pdf-module/src/test/java/org/apache/tika/parser/pdf/PDFPerPageRenderTest.java
@@ -0,0 +1,95 @@
+/*
+ * 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.assertNull;
+
+import java.io.ByteArrayInputStream;
+import java.io.InputStream;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.List;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import org.apache.tika.TikaTest;
+import org.apache.tika.io.TikaInputStream;
+import org.apache.tika.metadata.Metadata;
+import org.apache.tika.metadata.PagedText;
+import org.apache.tika.metadata.TikaCoreProperties;
+import org.apache.tika.parser.ParseContext;
+
+/**
+ * Per-page rendering re-opens the document once per page. Each open must
start from byte 0
+ * regardless of where the stream was left; before PDFRandomAccess the
renderer read the
+ * TikaInputStream from its current position, so page 1 drained it and every
later page
+ * rendered nothing, silently.
+ */
+public class PDFPerPageRenderTest extends TikaTest {
+
+ private static final String TWO_PAGES =
"/test-documents/testPDF_bookmarks.pdf";
+
+ @TempDir
+ Path tempDir;
+
+ private static ParseContext renderAtPageEnd() {
+ PDFParserConfig config = new PDFParserConfig();
+
config.setImageStrategy(PDFParserConfig.IMAGE_STRATEGY.RENDER_PAGES_AT_PAGE_END);
+ ParseContext context = new ParseContext();
+ context.set(PDFParserConfig.class, config);
+ return context;
+ }
+
+ private static void assertEveryPageRendered(List<Metadata> metadataList) {
+ Metadata container = metadataList.get(0);
+ assertNull(container.get(TikaCoreProperties.EMBEDDED_EXCEPTION),
+ "a page render failure is recorded on the container, not
thrown");
+ int pages = container.getInt(PagedText.N_PAGES);
+ assertEquals(2, pages, "fixture must be multi-page for this test to
mean anything");
+ long rendered = metadataList.stream()
+ .filter(m ->
TikaCoreProperties.EmbeddedResourceType.RENDERING.name()
+
.equals(m.get(TikaCoreProperties.EMBEDDED_RESOURCE_TYPE)))
+ .count();
+ assertEquals(pages, rendered, "one rendering per page");
+ }
+
+ @Test
+ public void testStreamBackedRendersEveryPage() throws Exception {
+ byte[] bytes;
+ try (InputStream is = getResourceAsStream(TWO_PAGES)) {
+ bytes = is.readAllBytes();
+ }
+ try (TikaInputStream tis = TikaInputStream.get(new
ByteArrayInputStream(bytes), new Metadata())) {
+ assertEveryPageRendered(getRecursiveMetadata(tis,
AUTO_DETECT_PARSER, new Metadata(),
+ renderAtPageEnd(), true));
+ }
+ }
+
+ @Test
+ public void testFileBackedRendersEveryPage() throws Exception {
+ Path pdf = tempDir.resolve("two-pages.pdf");
+ try (InputStream is = getResourceAsStream(TWO_PAGES)) {
+ Files.copy(is, pdf);
+ }
+ try (TikaInputStream tis = TikaInputStream.get(pdf)) {
+ assertEveryPageRendered(getRecursiveMetadata(tis,
AUTO_DETECT_PARSER, new Metadata(),
+ renderAtPageEnd(), true));
+ }
+ }
+}