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));
+    }
 }

Reply via email to