Skip to content

Commit 726bb2a

Browse files
authored
TIKA-4878: pdf and pst (#3133)
1 parent acab4a8 commit 726bb2a

4 files changed

Lines changed: 167 additions & 12 deletions

File tree

CHANGES.txt

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,12 @@
11
Release 4.1.0 - unreleased
22

3+
* PDF attachments, PDF XMP packets, 3D on-instantiate scripts and PST
4+
attachments are re-opened from their document instead of cached when
5+
the embedded-document extractor rewinds them (digesting does, for every
6+
embedded document). The cached copy cost heap for the whole attachment
7+
and, past the cache budget or the 1 MB floor, a temp file; PDFBox and
8+
java-libpst hand the bytes back on demand (TIKA-4878).
9+
310
* tika-server: named configuration presets (TIKA-4856).
411

512
* Temp files follow -Djava.io.tmpdir on the parent JVM (Tika, its

tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/pst/PSTMailItemParser.java

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
import org.apache.tika.exception.TikaException;
3636
import org.apache.tika.extractor.EmbeddedDocumentExtractor;
3737
import org.apache.tika.extractor.EmbeddedDocumentUtil;
38+
import org.apache.tika.io.TemporaryResources;
3839
import org.apache.tika.io.TikaInputStream;
3940
import org.apache.tika.metadata.HttpHeaders;
4041
import org.apache.tika.metadata.MAPI;
@@ -264,18 +265,23 @@ private void parseMailAttachment(XHTMLContentHandler xhtml, PSTAttachment attach
264265
attributes.addAttribute("", "id", "id", "CDATA", filename);
265266
xhtml.startElement("div", attributes);
266267
if (embeddedExtractor.shouldParseEmbedded(attachMeta, context)) {
267-
TikaInputStream tis = null;
268+
//open once now so a broken attachment is recorded here, not mid-parse
268269
try {
269-
tis = TikaInputStream.get(attachment.getFileInputStream());
270+
attachment.getFileInputStream().close();
270271
} catch (NullPointerException e) { //TIKA-2488
271272
EmbeddedDocumentUtil.recordEmbeddedStreamException(e, metadata, context);
272273
return;
273274
}
274-
275-
try {
275+
//the attachment is in the pst already: re-open it on rewind rather than
276+
//cache a copy that a digest of a large attachment would spill to disk
277+
try (TikaInputStream tis = TikaInputStream.get(() -> {
278+
try {
279+
return attachment.getFileInputStream();
280+
} catch (PSTException e) {
281+
throw new IOException(e);
282+
}
283+
}, new TemporaryResources(), null)) {
276284
embeddedExtractor.parseEmbedded(tis, xhtml, attachMeta, context, false);
277-
} finally {
278-
tis.close();
279285
}
280286
}
281287
xhtml.endElement("div");

tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-pdf-module/src/main/java/org/apache/tika/parser/pdf/AbstractPDF2XHTML.java

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -282,7 +282,8 @@ private void extractXMPXFA() throws IOException, SAXException {
282282
//try the main metadata
283283
if (pdDocument.getDocumentCatalog().getMetadata() != null) {
284284
try (TikaInputStream tis = TikaInputStream.get(
285-
pdDocument.getDocumentCatalog().getMetadata().exportXMPMetadata())) {
285+
() -> pdDocument.getDocumentCatalog().getMetadata().exportXMPMetadata(),
286+
new TemporaryResources(), null)) {
286287
extractXMPAsEmbeddedFile(tis, XMP_DOCUMENT_CATALOG_LOCATION);
287288
} catch (IOException e) {
288289
EmbeddedDocumentUtil.recordEmbeddedStreamException(e, metadata, context);
@@ -292,7 +293,9 @@ private void extractXMPXFA() throws IOException, SAXException {
292293
int pageNumber = 1;
293294
for (PDPage page : pdDocument.getPages()) {
294295
if (page.getMetadata() != null) {
295-
try (TikaInputStream tis = TikaInputStream.get(page.getMetadata().exportXMPMetadata())) {
296+
try (TikaInputStream tis = TikaInputStream.get(
297+
() -> page.getMetadata().exportXMPMetadata(),
298+
new TemporaryResources(), null)) {
296299
extractXMPAsEmbeddedFile(tis, XMP_PAGE_LOCATION_PREFIX + pageNumber);
297300
} catch (IOException e) {
298301
EmbeddedDocumentUtil.recordEmbeddedStreamException(e, metadata, context);
@@ -500,14 +503,17 @@ private void extractPDEmbeddedFile(String displayName, String annotationType,
500503
if (!embeddedDocumentExtractor.shouldParseEmbedded(embeddedMetadata, context)) {
501504
return;
502505
}
503-
TikaInputStream tis = null;
506+
//open once now so a broken stream is recorded here, not mid-parse
504507
try {
505-
tis = TikaInputStream.get(pdEmbeddedFile.createInputStream());
508+
pdEmbeddedFile.createInputStream().close();
506509
} catch (IOException e) {
507-
//store this exception in the parent's metadata
508510
EmbeddedDocumentUtil.recordEmbeddedStreamException(e, metadata, context);
509511
return;
510512
}
513+
//the file is in the document already: re-open (re-decode) it on rewind
514+
//rather than cache a copy that a digest of a large attachment would spill
515+
TikaInputStream tis = TikaInputStream.get(pdEmbeddedFile::createInputStream,
516+
new TemporaryResources(), null);
511517

512518
setOrReplaceAttribute("class", "embedded", attributes);
513519
setOrReplaceAttribute("id", fileName, attributes);
@@ -945,7 +951,8 @@ private void extractOnInstantiate(PDAnnotation annotation) throws IOException, S
945951
}
946952
Metadata m = getJavascriptMetadata("3DD_ON_INSTANTIATE", null, null);
947953
if (embeddedDocumentExtractor.shouldParseEmbedded(m, context)) {
948-
try (TikaInputStream tis = TikaInputStream.get(stream.createInputStream())) {
954+
try (TikaInputStream tis = TikaInputStream.get(stream::createInputStream,
955+
new TemporaryResources(), null)) {
949956
embeddedDocumentExtractor.parseEmbedded(tis, xhtml, m, context, true);
950957
}
951958
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
/*
2+
* Licensed to the Apache Software Foundation (ASF) under one or more
3+
* contributor license agreements. See the NOTICE file distributed with
4+
* this work for additional information regarding copyright ownership.
5+
* The ASF licenses this file to You under the Apache License, Version 2.0
6+
* (the "License"); you may not use this file except in compliance with
7+
* the License. You may obtain a copy of the License at
8+
*
9+
* http://www.apache.org/licenses/LICENSE-2.0
10+
*
11+
* Unless required by applicable law or agreed to in writing, software
12+
* distributed under the License is distributed on an "AS IS" BASIS,
13+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
* See the License for the specific language governing permissions and
15+
* limitations under the License.
16+
*/
17+
package org.apache.tika.parser.pdf;
18+
19+
import static org.junit.jupiter.api.Assertions.assertEquals;
20+
import static org.junit.jupiter.api.Assertions.assertTrue;
21+
22+
import java.io.ByteArrayInputStream;
23+
import java.io.IOException;
24+
import java.nio.file.Path;
25+
import java.util.ArrayList;
26+
import java.util.List;
27+
import java.util.Map;
28+
import java.util.Random;
29+
30+
import org.apache.pdfbox.cos.COSName;
31+
import org.apache.pdfbox.pdmodel.PDDocument;
32+
import org.apache.pdfbox.pdmodel.PDDocumentNameDictionary;
33+
import org.apache.pdfbox.pdmodel.PDEmbeddedFilesNameTreeNode;
34+
import org.apache.pdfbox.pdmodel.PDPage;
35+
import org.apache.pdfbox.pdmodel.common.filespecification.PDComplexFileSpecification;
36+
import org.apache.pdfbox.pdmodel.common.filespecification.PDEmbeddedFile;
37+
import org.junit.jupiter.api.Test;
38+
import org.junit.jupiter.api.io.TempDir;
39+
import org.xml.sax.ContentHandler;
40+
import org.xml.sax.helpers.DefaultHandler;
41+
42+
import org.apache.tika.TikaTest;
43+
import org.apache.tika.extractor.EmbeddedDocumentExtractor;
44+
import org.apache.tika.io.TikaInputStream;
45+
import org.apache.tika.metadata.Metadata;
46+
import org.apache.tika.parser.ParseContext;
47+
48+
/**
49+
* A PDF attachment's bytes are in the document already. Rewinding the stream
50+
* handed to the embedded-document extractor -- which digesting does for every
51+
* embedded document -- must re-open (re-decode) the attachment from the
52+
* document, not cache a copy of it and spill that copy to a temp file.
53+
* <p>
54+
* The payload is over the 1 MB a cache keeps in memory, so a cached stream has
55+
* to spill to rewind and the difference is observable. The assertion is on the
56+
* stream the extractor is handed: the parser owns the child's
57+
* {@code TemporaryResources}, so a watched directory would pass either way.
58+
*/
59+
public class PDFEmbeddedFileNoTempFileTest extends TikaTest {
60+
61+
private static final int PAYLOAD_LENGTH = 2 * 1024 * 1024;
62+
63+
@TempDir
64+
Path tempDir;
65+
66+
@Test
67+
public void testAttachmentIsNotSpooled() throws Exception {
68+
Path pdf = tempDir.resolve("attachment.pdf");
69+
byte[] payload = payload();
70+
try (PDDocument doc = new PDDocument()) {
71+
doc.addPage(new PDPage());
72+
//Flate-encoded, as real attachments are: a rewind has to re-decode
73+
PDEmbeddedFile file = new PDEmbeddedFile(doc, new ByteArrayInputStream(payload),
74+
COSName.FLATE_DECODE);
75+
file.setSize(payload.length);
76+
PDComplexFileSpecification spec = new PDComplexFileSpecification();
77+
spec.setFile("attachment.bin");
78+
spec.setEmbeddedFile(file);
79+
PDEmbeddedFilesNameTreeNode tree = new PDEmbeddedFilesNameTreeNode();
80+
tree.setNames(Map.of("attachment.bin", spec));
81+
PDDocumentNameDictionary names = new PDDocumentNameDictionary(doc.getDocumentCatalog());
82+
names.setEmbeddedFiles(tree);
83+
doc.getDocumentCatalog().setNames(names);
84+
doc.save(pdf.toFile());
85+
}
86+
87+
RecordingExtractor extractor = new RecordingExtractor();
88+
ParseContext context = new ParseContext();
89+
context.set(EmbeddedDocumentExtractor.class, extractor);
90+
Metadata metadata = new Metadata();
91+
try (TikaInputStream tis = TikaInputStream.get(pdf, metadata)) {
92+
new PDFParser().parse(tis, new DefaultHandler(), metadata, context);
93+
}
94+
95+
assertTrue(extractor.lengths.contains(PAYLOAD_LENGTH),
96+
"the attachment reached the extractor in full; saw " + extractor.lengths);
97+
for (int i = 0; i < extractor.spooled.size(); i++) {
98+
assertEquals(false, extractor.spooled.get(i), "embedded stream " + i + " ("
99+
+ extractor.lengths.get(i)
100+
+ " bytes) was spooled to disk to rewind instead of re-opened");
101+
}
102+
}
103+
104+
/** Incompressible filler, so Flate keeps it at full size. */
105+
private static byte[] payload() {
106+
byte[] bytes = new byte[PAYLOAD_LENGTH];
107+
new Random(4878).nextBytes(bytes);
108+
return bytes;
109+
}
110+
111+
/**
112+
* Rewinds each embedded stream the way a digester does, then records whether
113+
* that left it backed by a temp file and how many bytes it still yields.
114+
*/
115+
private static class RecordingExtractor implements EmbeddedDocumentExtractor {
116+
private final List<Boolean> spooled = new ArrayList<>();
117+
private final List<Integer> lengths = new ArrayList<>();
118+
119+
@Override
120+
public boolean shouldParseEmbedded(Metadata metadata, ParseContext context) {
121+
return true;
122+
}
123+
124+
@Override
125+
public void parseEmbedded(TikaInputStream stream, ContentHandler handler,
126+
Metadata metadata, ParseContext context, boolean outputHtml)
127+
throws IOException {
128+
stream.enableRewind();
129+
stream.readAllBytes();
130+
stream.rewind();
131+
spooled.add(stream.hasFile());
132+
lengths.add(stream.readAllBytes().length);
133+
}
134+
}
135+
}

0 commit comments

Comments
 (0)