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 654d70aaa1 TIKA-5033: Emit the PSD thumbnail resource as THUMBNAIL
(#3528)
654d70aaa1 is described below
commit 654d70aaa17866837b886568a2b0176896148612
Author: Dominik Schmidt <[email protected]>
AuthorDate: Thu Nov 5 14:44:03 2026 +0100
TIKA-5033: Emit the PSD thumbnail resource as THUMBNAIL (#3528)
---
CHANGES.txt | 4 ++
.../ROOT/pages/using-tika/server/index.adoc | 2 +-
.../org/apache/tika/parser/image/PSDParser.java | 77 +++++++++++++++++++++-
.../apache/tika/parser/image/PSDParserTest.java | 33 ++++++++++
4 files changed, 114 insertions(+), 2 deletions(-)
diff --git a/CHANGES.txt b/CHANGES.txt
index ba4e7f15d9..4bdf81c18d 100644
--- a/CHANGES.txt
+++ b/CHANGES.txt
@@ -56,6 +56,10 @@ Release 4.3.0 - ???
EXTRACTION AND FORMATS
+ * PSD files emit the JPEG thumbnail Photoshop stores in image resource
0x040C as a
+ THUMBNAIL embedded resource, so the thumbnail preset serves it; one over
+ maxDataLengthBytes is skipped with a warning (TIKA-5033).
+
* 3MF packages are parsed as zip packages instead of failing as XML: the
type is a
sub-type of application/zip, and the streaming zip detector recognizes it
(TIKA-5031).
diff --git a/docs/modules/ROOT/pages/using-tika/server/index.adoc
b/docs/modules/ROOT/pages/using-tika/server/index.adoc
index 113b2446fa..fe41bc690e 100644
--- a/docs/modules/ROOT/pages/using-tika/server/index.adoc
+++ b/docs/modules/ROOT/pages/using-tika/server/index.adoc
@@ -201,7 +201,7 @@ renders, so the presets do not make them. Both name the
standard parsers, so the
`/unpack/preset/thumbnail` — "just give me the thumbnail". The zip holds at
most one raster
image per request and nothing else: the stored thumbnail (Office docProps,
audio cover art,
-raw camera previews, epub, iWork), the rasterized EMF/WMF thumbnail, or the
PDF page; `204`
+raw camera previews, epub, PSD, iWork), the rasterized EMF/WMF thumbnail, or
the PDF page; `204`
when the document has none. An image file is its own thumbnail: it has no
embedded documents,
so it answers `204` too. Text is read from the first PDF page only, no OCR or
inference runs,
and embedded documents are parsed two levels deep at most, so the parse is
cheap.
diff --git
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-image-module/src/main/java/org/apache/tika/parser/image/PSDParser.java
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-image-module/src/main/java/org/apache/tika/parser/image/PSDParser.java
index 3ca28df23f..be205346b1 100644
---
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-image-module/src/main/java/org/apache/tika/parser/image/PSDParser.java
+++
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-image-module/src/main/java/org/apache/tika/parser/image/PSDParser.java
@@ -21,6 +21,7 @@ import static java.nio.charset.StandardCharsets.US_ASCII;
import java.io.EOFException;
import java.io.IOException;
import java.io.InputStream;
+import java.util.Arrays;
import java.util.Collections;
import java.util.HashSet;
import java.util.Set;
@@ -35,8 +36,11 @@ import org.apache.tika.config.ConfigDeserializer;
import org.apache.tika.config.JsonConfig;
import org.apache.tika.exception.TikaException;
import org.apache.tika.exception.TikaMemoryLimitException;
+import org.apache.tika.extractor.EmbeddedDocumentExtractor;
+import org.apache.tika.extractor.EmbeddedDocumentUtil;
import org.apache.tika.io.EndianUtils;
import org.apache.tika.io.TikaInputStream;
+import org.apache.tika.metadata.HttpHeaders;
import org.apache.tika.metadata.Metadata;
import org.apache.tika.metadata.Photoshop;
import org.apache.tika.metadata.TIFF;
@@ -54,6 +58,10 @@ import org.apache.tika.sax.XHTMLContentHandler;
* <p>
* An MIT-licensed python parser with test files is:
* https://github.com/psd-tools/psd-tools
+ * <p>
+ * The JPEG thumbnail Photoshop stores in image resource 0x040C is emitted as
the
+ * {@link TikaCoreProperties.EmbeddedResourceType#THUMBNAIL} embedded document.
+ * The raw BGR thumbnail of Photoshop 4.0 (0x0409) is not.
*/
@TikaComponent
public class PSDParser implements Parser {
@@ -68,6 +76,10 @@ public class PSDParser implements Parser {
private static final int MAX_DATA_LENGTH_BYTES = 10_000_000;
private static final int MAX_BLOCKS = 10000;
+ private static final String JPEG_MIME = "image/jpeg";
+ //format, width, height, widthbytes, total size, compressed size, bits per
pixel, planes
+ private static final int THUMBNAIL_HEADER_LENGTH = 28;
+ private static final int THUMBNAIL_FORMAT_JPEG = 1;
private PSDParserConfig defaultConfig = new PSDParserConfig();
@@ -141,6 +153,7 @@ public class PSDParser implements Parser {
//if something is corrupt about this number, prevent an
//infinite loop by only reading 10000 blocks
int blocks = 0;
+ byte[] thumbnail = null;
while (read < imageResourcesSectionSize && blocks < MAX_BLOCKS) {
ResourceBlock rb = new ResourceBlock(tis,
defaultConfig.getMaxDataLengthBytes());
if (rb.totalLength <= 0) {
@@ -161,6 +174,15 @@ public class PSDParser implements Parser {
ImageXmp.scanAndExtract(
UnsynchronizedByteArrayInputStream.builder().setByteArray(rb.data).get(),
metadata, context);
+ } else if (rb.id == ResourceBlock.ID_THUMBNAIL_RESOURCE) {
+ if (rb.skipped) {
+ EmbeddedDocumentUtil.recordException(new
TikaMemoryLimitException(
+ "thumbnail resource of " + rb.dataLength + " bytes
exceeds " +
+ defaultConfig.getMaxDataLengthBytes() + ";
skipped"),
+ metadata, context);
+ } else {
+ thumbnail = jpegThumbnail(rb.data);
+ }
}
blocks++;
}
@@ -172,9 +194,53 @@ public class PSDParser implements Parser {
// We don't have any helpful text, sorry...
XHTMLContentHandler xhtml = new XHTMLContentHandler(handler, metadata,
context);
xhtml.startDocument();
+ if (thumbnail != null) {
+ emitThumbnail(thumbnail, xhtml, context);
+ }
xhtml.endDocument();
}
+ private static byte[] jpegThumbnail(byte[] data) {
+ if (data.length < THUMBNAIL_HEADER_LENGTH + 2) {
+ return null;
+ }
+ int format = readIntBE(data, 0);
+ long compressedSize = readIntBE(data, 20) & 0xFFFFFFFFL;
+ if (format != THUMBNAIL_FORMAT_JPEG || compressedSize < 2 ||
+ compressedSize > data.length - THUMBNAIL_HEADER_LENGTH) {
+ return null;
+ }
+ if ((data[THUMBNAIL_HEADER_LENGTH] & 0xFF) != 0xFF ||
+ (data[THUMBNAIL_HEADER_LENGTH + 1] & 0xFF) != 0xD8) {
+ return null;
+ }
+ return Arrays.copyOfRange(data, THUMBNAIL_HEADER_LENGTH,
+ THUMBNAIL_HEADER_LENGTH + (int) compressedSize);
+ }
+
+ private static int readIntBE(byte[] data, int offset) {
+ return ((data[offset] & 0xFF) << 24) | ((data[offset + 1] & 0xFF) <<
16) |
+ ((data[offset + 2] & 0xFF) << 8) | (data[offset + 3] & 0xFF);
+ }
+
+ private static void emitThumbnail(byte[] jpeg, XHTMLContentHandler xhtml,
+ ParseContext context) throws
IOException, SAXException {
+ EmbeddedDocumentExtractor extractor =
+ EmbeddedDocumentUtil.getEmbeddedDocumentExtractor(context);
+ Metadata thumbnailMetadata = Metadata.newInstance(context);
+ thumbnailMetadata.set(HttpHeaders.CONTENT_TYPE, JPEG_MIME);
+ thumbnailMetadata.set(TikaCoreProperties.EMBEDDED_RESOURCE_TYPE,
+ TikaCoreProperties.EmbeddedResourceType.THUMBNAIL.toString());
+ EmbeddedDocumentUtil.setGeneratedResourceName(thumbnailMetadata,
+ EmbeddedDocumentUtil.EmbeddedResourcePrefix.THUMBNAIL, 0,
JPEG_MIME);
+ if (!extractor.shouldParseEmbedded(thumbnailMetadata, context)) {
+ return;
+ }
+ try (TikaInputStream thumbnailStream = TikaInputStream.get(jpeg)) {
+ extractor.parseEmbedded(thumbnailStream, xhtml, thumbnailMetadata,
context, true);
+ }
+ }
+
public PSDParserConfig getDefaultConfig() {
return defaultConfig;
}
@@ -195,6 +261,8 @@ public class PSDParser implements Parser {
private String name;
private byte[] data;
private int totalLength;
+ private int dataLength;
+ private boolean skipped;
private ResourceBlock(InputStream tis, int maxDataLengthBytes)
throws IOException, TikaException {
@@ -247,7 +315,13 @@ public class PSDParser implements Parser {
}
totalLength = 4 + 2 + nameLen + 4 + dataLen;
// Do we have use for the data segment?
- if (captureData(id)) {
+ dataLength = dataLen;
+ if (id == ID_THUMBNAIL_RESOURCE && dataLen > maxDataLengthBytes) {
+ //the thumbnail is an extra; the caller records a warning
instead of failing
+ skipped = true;
+ data = new byte[0];
+ IOUtils.skipFully(tis, dataLen);
+ } else if (captureData(id)) {
if (dataLen > maxDataLengthBytes) {
throw new TikaMemoryLimitException(
"data length must be < " + maxDataLengthBytes + ":
" + dataLen);
@@ -270,6 +344,7 @@ public class PSDParser implements Parser {
case ID_EXIF_1:
case ID_EXIF_3:
case ID_XMP:
+ case ID_THUMBNAIL_RESOURCE:
return true;
}
return false;
diff --git
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-image-module/src/test/java/org/apache/tika/parser/image/PSDParserTest.java
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-image-module/src/test/java/org/apache/tika/parser/image/PSDParserTest.java
index 0dcdaf9ac9..a3d88ac086 100644
---
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-image-module/src/test/java/org/apache/tika/parser/image/PSDParserTest.java
+++
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-image-module/src/test/java/org/apache/tika/parser/image/PSDParserTest.java
@@ -17,8 +17,11 @@
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.assertThrows;
+import java.util.List;
+
import org.junit.jupiter.api.Test;
import org.xml.sax.helpers.DefaultHandler;
@@ -29,6 +32,7 @@ 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.metadata.TikaCoreProperties;
import org.apache.tika.metadata.XMPMM;
import org.apache.tika.parser.ParseContext;
import org.apache.tika.parser.Parser;
@@ -89,4 +93,33 @@ public class PSDParserTest extends TikaTest {
getXML("testPSD_xmp.psd", p, metadata);
});
}
+
+ @Test
+ public void testThumbnail() throws Exception {
+ for (String file : new String[]{"testPSD2.psd", "testPSD_xmp.psd"}) {
+ List<Metadata> metadataList = getRecursiveMetadata(file);
+ assertEquals(2, metadataList.size(), file);
+ Metadata thumbnail = metadataList.get(1);
+
assertEquals(TikaCoreProperties.EmbeddedResourceType.THUMBNAIL.toString(),
+ thumbnail.get(TikaCoreProperties.EMBEDDED_RESOURCE_TYPE),
file);
+ assertEquals("image/jpeg",
thumbnail.get(HttpHeaders.CONTENT_TYPE), file);
+ }
+ }
+
+ @Test
+ public void testNoThumbnail() throws Exception {
+ assertEquals(1, getRecursiveMetadata("testPSD.psd").size());
+ }
+
+ @Test
+ public void testOversizedThumbnailIsSkippedWithWarning() throws Exception {
+ PSDParser.PSDParserConfig config = new PSDParser.PSDParserConfig();
+ config.setMaxDataLengthBytes(1000);
+ List<Metadata> metadataList = getRecursiveMetadata("testPSD2.psd",
+ new PSDParser(config), new Metadata(), new ParseContext(),
false);
+ assertEquals(1, metadataList.size());
+ assertContains("exceeds 1000",
+
metadataList.get(0).get(TikaCoreProperties.TIKA_META_EXCEPTION_WARNING));
+ assertNotNull(metadataList.get(0).get(TIFF.IMAGE_WIDTH));
+ }
}