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 55423a269c TIKA-5036: Blender preview as thumbnail (#3539)
55423a269c is described below
commit 55423a269c30704bf23451a61609b4580fb6773e
Author: Dominik Schmidt <[email protected]>
AuthorDate: Thu Nov 5 21:39:16 2026 +0100
TIKA-5036: Blender preview as thumbnail (#3539)
* TIKA-5036: Blender preview as thumbnail
A .blend file (application/x-blender, new) yields the preview Blender
stores in its TEST block as the THUMBNAIL embedded document, for both block
header layouts including Blender 5.0's. gzip- and zstd-compressed files are
detected by their content and read the same way; zstd needs zstd-jni on the
classpath, as for CompressorParser.
* TIKA-5036: also detect *.blender, as shared-mime-info does
* TIKA-5036: load nothing of zstd-jni when it is missing
The method returning a ZstdInputStream made the verifier load the class
with BlendCompression, so detection failed with NoClassDefFoundError without
zstd-jni. The reference moves into a nested class loaded only when zstd-jni is
there; a test hides zstd-jni from a class loader to cover that case.
* TIKA-5036: list Blender among the thumbnail sources
* TIKA-5036: bound what is skipped before the Blender preview
Skipping a block of a compressed file uncompresses it, and the length comes
from the file; more than 1 MB before the preview yields no preview. An
oversized preview throws TikaMemoryLimitException. A test checks that
EmbeddedLimits applies to the thumbnail.
* TIKA-5036: address review: gzip blends in GZipSpecializationDetector,
peek before reading
BlenderDetector read 136 KB of every input and returned x-blender for gzip
blends, which only won over GZipSpecializationDetector's application/gzip by
class name order. gzip blends are now recognized in GZipSpecializationDetector,
as warc.gz is; BlenderDetector handles zstd only and reads further only after
the four zstd magic bytes. BlenderParser closes the decompressing stream, the
input shielded. An integration test detects gzip and zstd blends with
DefaultDetector and with both d [...]
---
CHANGES.txt | 5 +
docs/modules/ROOT/pages/formats.adoc | 6 +
.../ROOT/pages/using-tika/server/index.adoc | 11 +-
docs/modules/ROOT/partials/supported-formats.adoc | 2 +
.../org/apache/tika/mime/tika-mimetypes.xml | 11 +
.../apache/tika/detect/BlenderDetectionTest.java | 118 +++++++
.../tika-parser-cad-module/pom.xml | 6 +
.../tika/detect/blender/BlenderDetector.java | 92 ++++++
.../tika/parser/blender/BlendCompression.java | 73 +++++
.../apache/tika/parser/blender/BlenderParser.java | 245 +++++++++++++++
.../tika/parser/blender/BlenderParserTest.java | 339 +++++++++++++++++++++
.../detect/gzip/GZipSpecializationDetector.java | 4 +
12 files changed, 907 insertions(+), 5 deletions(-)
diff --git a/CHANGES.txt b/CHANGES.txt
index 3e090af93b..406a6a186d 100644
--- a/CHANGES.txt
+++ b/CHANGES.txt
@@ -81,6 +81,11 @@ Release 4.3.0 - ???
EXTRACTION AND FORMATS
+ * Blender files (.blend, .blender, application/x-blender, new) yield the
preview Blender stores
+ as the THUMBNAIL embedded document, for every file format version
including the one
+ of Blender 5.0. A gzip- or zstd-compressed file is detected by its
content and read
+ the same way; zstd needs zstd-jni on the classpath (TIKA-5036).
+
* RAR5 archives are parsed: RarParser no longer refuses them, junrar has
read RAR5
since 8.0.0 (TIKA-5043).
diff --git a/docs/modules/ROOT/pages/formats.adoc
b/docs/modules/ROOT/pages/formats.adoc
index 24efe102f4..f04e24cb70 100644
--- a/docs/modules/ROOT/pages/formats.adoc
+++ b/docs/modules/ROOT/pages/formats.adoc
@@ -334,6 +334,12 @@ MicroStation DGN v8 files, and
link:{api}/org/apache/tika/parser/prt/PRTParser.html[PRTParser] from Cadkey
PRT files.
+link:{api}/org/apache/tika/parser/blender/BlenderParser.html[BlenderParser]
emits the
+preview Blender stores in a `.blend` file as the THUMBNAIL embedded document.
Files
+compressed with gzip are read as they are; files compressed with zstd, as
Blender 3.0 and
+later save them with "Compress" on, need zstd-jni on the classpath, as the
compressor
+formats do.
+
== Font formats
link:{api}/org/apache/tika/parser/font/TrueTypeParser.html[TrueTypeParser]
diff --git a/docs/modules/ROOT/pages/using-tika/server/index.adoc
b/docs/modules/ROOT/pages/using-tika/server/index.adoc
index 03bb991fa8..f74b7c3981 100644
--- a/docs/modules/ROOT/pages/using-tika/server/index.adoc
+++ b/docs/modules/ROOT/pages/using-tika/server/index.adoc
@@ -201,11 +201,12 @@ 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, Mobipocket, PSD, FictionBook, iWork, the cover of a
cbz, the preview of
-Krita and other zip-based formats), 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.
+camera previews, epub, Mobipocket, PSD, FictionBook, Blender, iWork, the cover
of a cbz, the
+preview of Krita and other zip-based formats), 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.
`/rmeta/preset/render-thumbnail` — "give me everything, thumbnail included". A
full parse
plus the two renders above, so an index gets the thumbnail's type and
dimensions from the same
diff --git a/docs/modules/ROOT/partials/supported-formats.adoc
b/docs/modules/ROOT/partials/supported-formats.adoc
index cbeb833c5f..5202459962 100644
--- a/docs/modules/ROOT/partials/supported-formats.adoc
+++ b/docs/modules/ROOT/partials/supported-formats.adoc
@@ -36,6 +36,8 @@
* `org.apache.tika.parser.audio.`
link:{tika-javadoc-url}/org/apache/tika/parser/audio/MidiParser.html[MidiParser]
** `application/x-midi`
** `audio/midi`
+* `org.apache.tika.parser.blender.`
link:{tika-javadoc-url}/org/apache/tika/parser/blender/BlenderParser.html[BlenderParser]
+** `application/x-blender`
* `org.apache.tika.parser.code.`
link:{tika-javadoc-url}/org/apache/tika/parser/code/SourceCodeParser.html[SourceCodeParser]
** `text/x-c++src`
** `text/x-groovy`
diff --git
a/tika-core/src/main/resources/org/apache/tika/mime/tika-mimetypes.xml
b/tika-core/src/main/resources/org/apache/tika/mime/tika-mimetypes.xml
index 6d2ca59ed6..ab742aed97 100644
--- a/tika-core/src/main/resources/org/apache/tika/mime/tika-mimetypes.xml
+++ b/tika-core/src/main/resources/org/apache/tika/mime/tika-mimetypes.xml
@@ -3849,6 +3849,17 @@
<glob pattern="*.torrent"/>
</mime-type>
+ <mime-type type="application/x-blender">
+ <acronym>Blend</acronym>
+ <_comment>Blender scene</_comment>
+ <tika:link>https://www.blender.org/</tika:link>
+ <magic priority="50">
+ <match value="BLENDER" type="string" offset="0"/>
+ </magic>
+ <glob pattern="*.blend"/>
+ <glob pattern="*.blender"/>
+ </mime-type>
+
<mime-type type="application/x-bplist">
<!-- this is the binary verision of x-plist -->
<!-- Check for well-known bplist versions -->
diff --git
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-integration-tests/src/test/java/org/apache/tika/detect/BlenderDetectionTest.java
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-integration-tests/src/test/java/org/apache/tika/detect/BlenderDetectionTest.java
new file mode 100644
index 0000000000..ccc5955b68
--- /dev/null
+++
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-integration-tests/src/test/java/org/apache/tika/detect/BlenderDetectionTest.java
@@ -0,0 +1,118 @@
+/*
+ * 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;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.nio.ByteBuffer;
+import java.nio.ByteOrder;
+import java.nio.charset.StandardCharsets;
+import java.util.Arrays;
+import java.util.List;
+import java.util.zip.GZIPOutputStream;
+
+import com.github.luben.zstd.Zstd;
+import org.junit.jupiter.api.Test;
+
+import org.apache.tika.TikaTest;
+import org.apache.tika.detect.blender.BlenderDetector;
+import org.apache.tika.detect.gzip.GZipSpecializationDetector;
+import org.apache.tika.io.TikaInputStream;
+import org.apache.tika.metadata.Metadata;
+import org.apache.tika.metadata.TikaCoreProperties;
+import org.apache.tika.mime.MediaTypeRegistry;
+import org.apache.tika.parser.ParseContext;
+
+/**
+ * Compressed Blender files with every standard detector: gzip ones are
recognized by
+ * GZipSpecializationDetector, zstd ones by BlenderDetector, whatever order
they run in.
+ */
+public class BlenderDetectionTest extends TikaTest {
+
+ @Test
+ public void testDefaultDetector() throws Exception {
+ byte[] blend = blend();
+ for (byte[] file : new byte[][]{blend, gzip(blend),
Zstd.compress(blend)}) {
+ assertEquals("application/x-blender", detect(new
DefaultDetector(), file));
+ }
+ byte[] other = "BLENDEX and more".getBytes(StandardCharsets.US_ASCII);
+ assertEquals("application/gzip", detect(new DefaultDetector(),
gzip(other)));
+ assertEquals("application/zstd", detect(new DefaultDetector(),
Zstd.compress(other)));
+ }
+
+ @Test
+ public void testDetectorOrder() throws Exception {
+ MediaTypeRegistry registry = MediaTypeRegistry.getDefaultRegistry();
+ Detector gzipDetector = new GZipSpecializationDetector();
+ Detector blenderDetector = new BlenderDetector();
+ byte[] blend = blend();
+ for (List<Detector> order : List.of(List.of(gzipDetector,
blenderDetector),
+ List.of(blenderDetector, gzipDetector))) {
+ for (byte[] file : new byte[][]{gzip(blend),
Zstd.compress(blend)}) {
+ assertEquals("application/x-blender",
+ detect(new CompositeDetector(registry, order), file));
+ }
+ }
+ }
+
+ @Test
+ public void testCompressedFilesYieldTheirPreview() throws Exception {
+ byte[] blend = blend();
+ for (byte[] file : new byte[][]{gzip(blend), Zstd.compress(blend)}) {
+ List<Metadata> list;
+ try (TikaInputStream tis = TikaInputStream.get(file)) {
+ list = getRecursiveMetadata(tis, true);
+ }
+ assertEquals(2, list.size());
+
assertEquals(TikaCoreProperties.EmbeddedResourceType.THUMBNAIL.toString(),
+
list.get(1).get(TikaCoreProperties.EMBEDDED_RESOURCE_TYPE));
+ }
+ }
+
+ private static String detect(Detector detector, byte[] file) throws
IOException {
+ try (TikaInputStream tis = TikaInputStream.get(file)) {
+ return detector.detect(tis, new Metadata(), new
ParseContext()).toString();
+ }
+ }
+
+ /** A blend file of Blender 4: header, REND, a 2x1 preview in TEST, ENDB.
*/
+ private static byte[] blend() {
+ ByteBuffer out =
ByteBuffer.allocate(256).order(ByteOrder.LITTLE_ENDIAN);
+ out.put("BLENDER-v402".getBytes(StandardCharsets.US_ASCII));
+ block(out, "REND", new byte[72]);
+ ByteBuffer preview = ByteBuffer.allocate(8 + 2 *
4).order(ByteOrder.LITTLE_ENDIAN);
+ preview.putInt(2).putInt(1).putInt(0xff0000ff).putInt(0xff00ff00);
+ block(out, "TEST", preview.array());
+ block(out, "ENDB", new byte[0]);
+ return Arrays.copyOf(out.array(), out.position());
+ }
+
+ private static void block(ByteBuffer out, String code, byte[] data) {
+
out.put(code.getBytes(StandardCharsets.US_ASCII)).putInt(data.length).putLong(0)
+ .putInt(0).putInt(1).put(data);
+ }
+
+ private static byte[] gzip(byte[] data) throws IOException {
+ ByteArrayOutputStream bytes = new ByteArrayOutputStream();
+ try (GZIPOutputStream gz = new GZIPOutputStream(bytes)) {
+ gz.write(data);
+ }
+ return bytes.toByteArray();
+ }
+}
diff --git
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-cad-module/pom.xml
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-cad-module/pom.xml
index a5f95abdae..9c9fb4fe0e 100644
---
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-cad-module/pom.xml
+++
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-cad-module/pom.xml
@@ -40,6 +40,12 @@
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-core</artifactId>
</dependency>
+ <dependency>
+ <groupId>com.github.luben</groupId>
+ <artifactId>zstd-jni</artifactId>
+ <version>${zstd.version}</version>
+ <scope>provided</scope>
+ </dependency>
</dependencies>
<properties>
<!-- TIKA-4816 metadata string-key ban, main sources; see tika-parent -->
diff --git
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-cad-module/src/main/java/org/apache/tika/detect/blender/BlenderDetector.java
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-cad-module/src/main/java/org/apache/tika/detect/blender/BlenderDetector.java
new file mode 100644
index 0000000000..cd18626bcc
--- /dev/null
+++
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-cad-module/src/main/java/org/apache/tika/detect/blender/BlenderDetector.java
@@ -0,0 +1,92 @@
+/*
+ * 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.blender;
+
+import java.io.ByteArrayInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.charset.StandardCharsets;
+import java.util.Arrays;
+
+import org.apache.tika.annotation.TikaComponent;
+import org.apache.tika.detect.Detector;
+import org.apache.tika.io.TikaInputStream;
+import org.apache.tika.metadata.Metadata;
+import org.apache.tika.mime.MediaType;
+import org.apache.tika.parser.ParseContext;
+import org.apache.tika.parser.blender.BlendCompression;
+
+/**
+ * Detects a zstd-compressed Blender file, which its zstd magic would
otherwise make a zstd
+ * archive, by the "BLENDER" its content starts with. Uncompressed files are
left to their
+ * magic, gzip-compressed ones to {@code GZipSpecializationDetector}.
+ */
+@TikaComponent
+public class BlenderDetector implements Detector {
+
+ private static final long serialVersionUID = 1L;
+
+ static final MediaType BLENDER = MediaType.application("x-blender");
+
+ private static final byte[] MAGIC =
"BLENDER".getBytes(StandardCharsets.US_ASCII);
+
+ private static final byte[] ZSTD = {(byte) 0x28, (byte) 0xb5, (byte) 0x2f,
(byte) 0xfd};
+
+ /**
+ * How much of the stream is read to uncompress the magic: a zstd block
holds up to
+ * 128 KB, and Blender's first frame holds the file header with it.
+ */
+ static final int MAX_PREFIX_LENGTH = 136 * 1024;
+
+ @Override
+ public MediaType detect(TikaInputStream tis, Metadata metadata,
ParseContext parseContext)
+ throws IOException {
+ if (tis == null) {
+ return MediaType.OCTET_STREAM;
+ }
+ tis.mark(ZSTD.length);
+ byte[] head;
+ try {
+ head = tis.readNBytes(ZSTD.length);
+ } finally {
+ tis.reset();
+ }
+ if (!Arrays.equals(head, ZSTD)) {
+ return MediaType.OCTET_STREAM;
+ }
+ tis.mark(MAX_PREFIX_LENGTH);
+ byte[] prefix;
+ try {
+ prefix = tis.readNBytes(MAX_PREFIX_LENGTH);
+ } finally {
+ tis.reset();
+ }
+ try (InputStream in = BlendCompression.open(new
ByteArrayInputStream(prefix))) {
+ if (in != null && startsWithMagic(in.readNBytes(MAGIC.length))) {
+ return BLENDER;
+ }
+ } catch (IOException e) {
+ // not zstd after all, or the first frame is longer than the prefix
+ }
+ return MediaType.OCTET_STREAM;
+ }
+
+ private static boolean startsWithMagic(byte[] bytes) {
+ return bytes.length >= MAGIC.length
+ && Arrays.equals(bytes, 0, MAGIC.length, MAGIC, 0,
MAGIC.length);
+ }
+}
diff --git
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-cad-module/src/main/java/org/apache/tika/parser/blender/BlendCompression.java
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-cad-module/src/main/java/org/apache/tika/parser/blender/BlendCompression.java
new file mode 100644
index 0000000000..e1d20dd879
--- /dev/null
+++
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-cad-module/src/main/java/org/apache/tika/parser/blender/BlendCompression.java
@@ -0,0 +1,73 @@
+/*
+ * 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.blender;
+
+import java.io.BufferedInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.zip.GZIPInputStream;
+
+/**
+ * Blender saves a file as it is, gzip-compressed (up to 2.9x) or
zstd-compressed (from 3.0),
+ * whichever the "Compress" option asks for. zstd needs zstd-jni on the
classpath, as it does
+ * for the {@code CompressorParser}.
+ */
+public final class BlendCompression {
+
+ private static final boolean ZSTD_AVAILABLE = zstdAvailable();
+
+ private BlendCompression() {
+ }
+
+ /**
+ * The uncompressed content of a blend file, or {@code null} for zstd
without zstd-jni.
+ * The stream is wrapped, not closed, when it is compressed.
+ */
+ public static InputStream open(InputStream stream) throws IOException {
+ InputStream in = stream.markSupported() ? stream : new
BufferedInputStream(stream);
+ in.mark(4);
+ byte[] head = in.readNBytes(4);
+ in.reset();
+ if (head.length >= 2 && (head[0] & 0xff) == 0x1f && (head[1] & 0xff)
== 0x8b) {
+ return new GZIPInputStream(in);
+ }
+ if (head.length == 4 && (head[0] & 0xff) == 0x28 && (head[1] & 0xff)
== 0xb5
+ && (head[2] & 0xff) == 0x2f && (head[3] & 0xff) == 0xfd) {
+ return ZSTD_AVAILABLE ? Zstd.open(in) : null;
+ }
+ return in;
+ }
+
+ /**
+ * Holds the only reference to zstd-jni: verifying a method that returns
its stream loads
+ * the class, so the method lives in a class that is loaded only when
zstd-jni is there.
+ */
+ private static final class Zstd {
+ static InputStream open(InputStream in) throws IOException {
+ return new com.github.luben.zstd.ZstdInputStream(in);
+ }
+ }
+
+ private static boolean zstdAvailable() {
+ try {
+ Class.forName("com.github.luben.zstd.ZstdInputStream");
+ return true;
+ } catch (ClassNotFoundException | LinkageError e) {
+ return false;
+ }
+ }
+}
diff --git
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-cad-module/src/main/java/org/apache/tika/parser/blender/BlenderParser.java
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-cad-module/src/main/java/org/apache/tika/parser/blender/BlenderParser.java
new file mode 100644
index 0000000000..eca26d6ef5
--- /dev/null
+++
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-cad-module/src/main/java/org/apache/tika/parser/blender/BlenderParser.java
@@ -0,0 +1,245 @@
+/*
+ * 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.blender;
+
+import java.awt.image.BufferedImage;
+import java.io.ByteArrayOutputStream;
+import java.io.EOFException;
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.charset.StandardCharsets;
+import java.util.Collections;
+import java.util.Set;
+import javax.imageio.ImageIO;
+
+import org.apache.commons.io.IOUtils;
+import org.apache.commons.io.input.CloseShieldInputStream;
+import org.xml.sax.ContentHandler;
+import org.xml.sax.SAXException;
+
+import org.apache.tika.annotation.TikaComponent;
+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.TikaCoreProperties;
+import org.apache.tika.mime.MediaType;
+import org.apache.tika.parser.ParseContext;
+import org.apache.tika.parser.Parser;
+import org.apache.tika.sax.EmbeddedContentHandler;
+import org.apache.tika.sax.XHTMLContentHandler;
+
+/**
+ * Extracts the preview Blender stores in a .blend file as the THUMBNAIL
embedded document.
+ * <p>
+ * The file header is "BLENDER", then either the pointer size ('_' 4, '-' 8
bytes), the byte
+ * order ('v' little, 'V' big endian) and three version digits, or, from
Blender 5.0, the
+ * header length and the file format version as two digits each, separated by
'-', the byte
+ * order and four version digits. Blocks follow, each a code of four
characters, the data's
+ * length, an address, the type of its data and the number of items, then the
data. Format
+ * version 0 orders them code, length (32 bits), address (pointer size), type
(32 bits), count
+ * (32 bits); version 1 code, type (32 bits), address, length and count (64
bits each).
+ * <p>
+ * Blender writes the preview as the "TEST" block after the "REND" one: width
and height as
+ * 32-bit integers, then RGBA bytes, the bottom row first. A file saved
without one, as by an
+ * older version or from a script, yields no thumbnail.
+ */
+@TikaComponent
+public class BlenderParser implements Parser {
+
+ private static final long serialVersionUID = 1L;
+
+ private static final MediaType BLENDER =
MediaType.application("x-blender");
+
+ private static final Set<MediaType> SUPPORTED_TYPES =
Collections.singleton(BLENDER);
+
+ private static final String MAGIC = "BLENDER";
+
+ /** The preview is the second block; a few more are looked at before
giving up. */
+ static final int MAX_BLOCKS = 16;
+
+ /** Blender's previews are 128 pixels wide; this bounds the memory a bad
header costs. */
+ static final int MAX_THUMBNAIL_SIDE = 1024;
+
+ /**
+ * How much is skipped to reach the preview; the REND block before it is
some 70 bytes.
+ * Skipping a compressed file means uncompressing it, so a bad length must
not lead far.
+ */
+ static final long MAX_SKIPPED_BYTES = 1024 * 1024;
+
+ @Override
+ public Set<MediaType> getSupportedTypes(ParseContext context) {
+ return SUPPORTED_TYPES;
+ }
+
+ @Override
+ public void parse(TikaInputStream tis, ContentHandler handler, Metadata
metadata,
+ ParseContext context) throws IOException, SAXException,
TikaException {
+ metadata.set(HttpHeaders.CONTENT_TYPE, BLENDER.toString());
+ byte[] thumbnail;
+ try (InputStream in =
BlendCompression.open(CloseShieldInputStream.wrap(tis))) {
+ if (in == null) {
+ throw new TikaException("The file is zstd-compressed, and
zstd-jni is not on the "
+ + "classpath");
+ }
+ thumbnail = readThumbnail(in);
+ }
+ XHTMLContentHandler xhtml = new XHTMLContentHandler(handler, metadata,
context);
+ xhtml.startDocument();
+ try {
+ if (thumbnail != null) {
+ emitThumbnail(thumbnail, xhtml, context);
+ }
+ } finally {
+ xhtml.endDocument();
+ }
+ }
+
+ /** The preview as a PNG, or {@code null} when the file has none. */
+ static byte[] readThumbnail(InputStream in) throws IOException,
TikaException {
+ String magic = ascii(in, MAGIC.length());
+ if (!MAGIC.equals(magic)) {
+ throw new TikaException("Not a Blender file");
+ }
+ char next = (char) readByte(in);
+ boolean largeBlocks;
+ int pointerSize;
+ boolean littleEndian;
+ if (next == '_' || next == '-') {
+ pointerSize = next == '_' ? 4 : 8;
+ littleEndian = byteOrder(readByte(in));
+ IOUtils.skipFully(in, 3);
+ largeBlocks = false;
+ } else {
+ String digits = next + ascii(in, 1);
+ if (!digits.matches("[0-9]{2}") || Integer.parseInt(digits) <
MAGIC.length() + 6) {
+ throw new TikaException("Bad Blender file header length: " +
digits);
+ }
+ String rest = ascii(in, Integer.parseInt(digits) - MAGIC.length()
- 2);
+ if (!rest.startsWith("-01")) {
+ throw new TikaException("Unsupported Blender file format
version: " + rest);
+ }
+ littleEndian = byteOrder(rest.charAt(3));
+ pointerSize = 8;
+ largeBlocks = true;
+ }
+ long skipped = 0;
+ for (int i = 0; i < MAX_BLOCKS; i++) {
+ String code = ascii(in, 4);
+ long length;
+ if (largeBlocks) {
+ IOUtils.skipFully(in, 4 + 8);
+ length = littleEndian ? EndianUtils.readLongLE(in) :
EndianUtils.readLongBE(in);
+ IOUtils.skipFully(in, 8);
+ } else {
+ length = Integer.toUnsignedLong(readInt(in, littleEndian));
+ IOUtils.skipFully(in, pointerSize + 4 + 4);
+ }
+ if (length < 0) {
+ throw new TikaException("Bad block length " + length + " for "
+ code);
+ }
+ if ("TEST".equals(code)) {
+ return png(in, length, littleEndian);
+ }
+ if ("ENDB".equals(code)) {
+ return null;
+ }
+ skipped += length;
+ if (skipped > MAX_SKIPPED_BYTES) {
+ return null;
+ }
+ IOUtils.skipFully(in, length);
+ }
+ return null;
+ }
+
+ private static byte[] png(InputStream in, long length, boolean
littleEndian)
+ throws IOException, TikaException {
+ int width = readInt(in, littleEndian);
+ int height = readInt(in, littleEndian);
+ if (width <= 0 || height <= 0 || 8L + 4L * width * height > length) {
+ throw new TikaException("Bad preview size " + width + "x" + height
+ " in a "
+ + length + "-byte block");
+ }
+ if (width > MAX_THUMBNAIL_SIDE || height > MAX_THUMBNAIL_SIDE) {
+ throw new TikaMemoryLimitException(4L * width * height,
+ 4L * MAX_THUMBNAIL_SIDE * MAX_THUMBNAIL_SIDE);
+ }
+ byte[] rgba = IOUtils.toByteArray(in, 4 * width * height);
+ BufferedImage image = new BufferedImage(width, height,
BufferedImage.TYPE_INT_ARGB);
+ for (int y = 0; y < height; y++) {
+ int row = (height - 1 - y) * width * 4;
+ for (int x = 0; x < width; x++) {
+ int p = row + 4 * x;
+ image.setRGB(x, y, (rgba[p + 3] & 0xff) << 24 | (rgba[p] &
0xff) << 16
+ | (rgba[p + 1] & 0xff) << 8 | (rgba[p + 2] & 0xff));
+ }
+ }
+ ByteArrayOutputStream png = new ByteArrayOutputStream();
+ ImageIO.write(image, "png", png);
+ return png.toByteArray();
+ }
+
+ private static void emitThumbnail(byte[] png, XHTMLContentHandler xhtml,
+ ParseContext context) throws
IOException, SAXException {
+ EmbeddedDocumentExtractor extractor =
+ EmbeddedDocumentUtil.getEmbeddedDocumentExtractor(context);
+ Metadata thumbnail = new Metadata();
+ thumbnail.set(TikaCoreProperties.RESOURCE_NAME_KEY, "thumbnail.png");
+ thumbnail.set(HttpHeaders.CONTENT_TYPE, "image/png");
+ thumbnail.set(TikaCoreProperties.EMBEDDED_RESOURCE_TYPE,
+ TikaCoreProperties.EmbeddedResourceType.THUMBNAIL.toString());
+ if (!extractor.shouldParseEmbedded(thumbnail, context)) {
+ return;
+ }
+ try (TikaInputStream image = TikaInputStream.get(png)) {
+ extractor.parseEmbedded(image, new EmbeddedContentHandler(xhtml),
thumbnail, context,
+ true);
+ }
+ }
+
+ private static boolean byteOrder(int b) throws TikaException {
+ if (b == 'v') {
+ return true;
+ }
+ if (b == 'V') {
+ return false;
+ }
+ throw new TikaException("Bad byte order mark " + b);
+ }
+
+ private static int readInt(InputStream in, boolean littleEndian)
+ throws IOException, TikaException {
+ return littleEndian ? EndianUtils.readIntLE(in) :
EndianUtils.readIntBE(in);
+ }
+
+ private static int readByte(InputStream in) throws IOException {
+ int b = in.read();
+ if (b < 0) {
+ throw new EOFException();
+ }
+ return b;
+ }
+
+ private static String ascii(InputStream in, int length) throws IOException
{
+ return new String(IOUtils.toByteArray(in, length),
StandardCharsets.US_ASCII);
+ }
+}
diff --git
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-cad-module/src/test/java/org/apache/tika/parser/blender/BlenderParserTest.java
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-cad-module/src/test/java/org/apache/tika/parser/blender/BlenderParserTest.java
new file mode 100644
index 0000000000..82b13d7825
--- /dev/null
+++
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-cad-module/src/test/java/org/apache/tika/parser/blender/BlenderParserTest.java
@@ -0,0 +1,339 @@
+/*
+ * 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.blender;
+
+import static org.junit.jupiter.api.Assertions.assertArrayEquals;
+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.assertThrows;
+
+import java.awt.image.BufferedImage;
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.lang.reflect.Method;
+import java.net.URL;
+import java.net.URLClassLoader;
+import java.nio.ByteBuffer;
+import java.nio.ByteOrder;
+import java.nio.charset.StandardCharsets;
+import java.util.Arrays;
+import java.util.List;
+import java.util.zip.GZIPOutputStream;
+import javax.imageio.ImageIO;
+
+import com.github.luben.zstd.Zstd;
+import org.junit.jupiter.api.Test;
+import org.xml.sax.helpers.DefaultHandler;
+
+import org.apache.tika.TikaTest;
+import org.apache.tika.config.EmbeddedLimits;
+import org.apache.tika.detect.DefaultDetector;
+import org.apache.tika.detect.blender.BlenderDetector;
+import org.apache.tika.exception.TikaException;
+import org.apache.tika.exception.TikaMemoryLimitException;
+import org.apache.tika.io.TikaInputStream;
+import org.apache.tika.metadata.HttpHeaders;
+import org.apache.tika.metadata.Metadata;
+import org.apache.tika.metadata.TikaCoreProperties;
+import org.apache.tika.mime.MediaType;
+import org.apache.tika.parser.ParseContext;
+
+public class BlenderParserTest extends TikaTest {
+
+ /** A 3x2 preview: the rows as Blender stores them, the bottom one first.
*/
+ private static final int[][] BOTTOM_UP = {
+ {0xff0000ff, 0x00ff00ff, 0x0000ff80},
+ {0xffffffff, 0x000000ff, 0x11223344}};
+
+ private enum Header { POINTER4_BIG_ENDIAN, POINTER8, LARGE_BLOCKS }
+
+ @Test
+ public void testPreviewOfEveryHeader() throws Exception {
+ for (Header header : Header.values()) {
+ List<Metadata> list = parse(blend(header, true));
+ assertEquals(2, list.size(), header.toString());
+ assertEquals("application/x-blender",
list.get(0).get(HttpHeaders.CONTENT_TYPE));
+ assertPreview(list.get(1), header.toString());
+ }
+ }
+
+ @Test
+ public void testCompressedFiles() throws Exception {
+ byte[] blend = blend(Header.POINTER8, true);
+ for (byte[] file : new byte[][]{gzip(blend), Zstd.compress(blend)}) {
+ assertArrayEquals(BlenderParser.readThumbnail(new
ByteArrayInputStream(blend)),
+ BlenderParser.readThumbnail(BlendCompression.open(
+ new ByteArrayInputStream(file))));
+ }
+ // zstd is detected here; gzip by GZipSpecializationDetector, see the
integration tests
+ List<Metadata> list = parse(Zstd.compress(blend));
+ assertEquals("application/x-blender",
list.get(0).get(HttpHeaders.CONTENT_TYPE));
+ assertEquals(2, list.size());
+ assertPreview(list.get(1), "compressed");
+ }
+
+ @Test
+ public void testDetection() throws Exception {
+ byte[] blend = blend(Header.LARGE_BLOCKS, true);
+ assertEquals("application/x-blender", detect(blend, null));
+ assertEquals("application/x-blender", detect(Zstd.compress(blend),
null));
+ assertEquals("application/x-blender", detect(new byte[16],
"scene.blend"));
+ assertEquals("application/x-blender", detect(new byte[16],
"scene.blender"));
+ byte[] other = "BLENDEX and more".getBytes(StandardCharsets.US_ASCII);
+ assertEquals("application/gzip", detect(gzip(other), "scene.blend"));
+ assertEquals("application/zstd", detect(Zstd.compress(other),
"scene.blend"));
+ }
+
+ /**
+ * Without zstd-jni, a zstd-compressed file is not taken for a Blender
file and fails
+ * nothing; gzip still opens.
+ */
+ @Test
+ public void testWithoutZstdJni() throws Exception {
+ byte[] blend = blend(Header.POINTER8, true);
+ try (WithoutZstdJni loader = new WithoutZstdJni()) {
+ Class<?> compression =
loader.loadClass(BlendCompression.class.getName());
+ Method open = compression.getMethod("open", InputStream.class);
+ assertNull(open.invoke(null, new
ByteArrayInputStream(Zstd.compress(blend))));
+ assertNotNull(open.invoke(null, new
ByteArrayInputStream(gzip(blend))));
+
+ Object detector = loader.loadClass(BlenderDetector.class.getName())
+ .getConstructor().newInstance();
+ Method detect = detector.getClass().getMethod("detect",
TikaInputStream.class,
+ Metadata.class, ParseContext.class);
+ try (TikaInputStream zstd =
TikaInputStream.get(Zstd.compress(blend))) {
+ assertEquals(MediaType.OCTET_STREAM,
+ detect.invoke(detector, zstd, new Metadata(), new
ParseContext()));
+ }
+ }
+ }
+
+ /** Loads the Blender classes itself and has no zstd-jni; the rest comes
from the parent. */
+ private static final class WithoutZstdJni extends URLClassLoader {
+ WithoutZstdJni() {
+ super(new
URL[]{BlendCompression.class.getProtectionDomain().getCodeSource()
+ .getLocation()}, BlenderParserTest.class.getClassLoader());
+ }
+
+ @Override
+ protected Class<?> loadClass(String name, boolean resolve)
+ throws ClassNotFoundException {
+ if (name.startsWith("com.github.luben.")) {
+ throw new ClassNotFoundException(name);
+ }
+ if (name.startsWith("org.apache.tika.parser.blender.")
+ || name.startsWith("org.apache.tika.detect.blender.")) {
+ synchronized (getClassLoadingLock(name)) {
+ Class<?> c = findLoadedClass(name);
+ return c != null ? c : findClass(name);
+ }
+ }
+ return super.loadClass(name, resolve);
+ }
+ }
+
+ @Test
+ public void testNoPreview() throws Exception {
+ List<Metadata> list = parse(blend(Header.POINTER8, false));
+ assertEquals(1, list.size());
+ assertEquals("application/x-blender",
list.get(0).get(HttpHeaders.CONTENT_TYPE));
+ }
+
+ @Test
+ public void testBadPreviewSize() throws Exception {
+ byte[] file = blend(Header.POINTER8, true);
+ int test = indexOf(file, "TEST") + 4 + 4 + 8 + 4 + 4;
+ ByteBuffer.wrap(file).order(ByteOrder.LITTLE_ENDIAN).putInt(test,
100000);
+ assertThrows(TikaException.class, () -> parseDirectly(file));
+ }
+
+ /** A preview larger than the cap, its block as long as it claims. */
+ @Test
+ public void testOversizedPreview() throws Exception {
+ int side = BlenderParser.MAX_THUMBNAIL_SIDE + 1;
+ ByteBuffer out =
ByteBuffer.allocate(64).order(ByteOrder.LITTLE_ENDIAN);
+ out.put("BLENDER-v402TEST".getBytes(StandardCharsets.US_ASCII))
+ .putInt(8 + 4 * side * side).putLong(0).putInt(0).putInt(1)
+ .putInt(side).putInt(side);
+ byte[] file = Arrays.copyOf(out.array(), out.position());
+ assertThrows(TikaMemoryLimitException.class, () ->
parseDirectly(file));
+ }
+
+ /**
+ * A block before the preview claiming more than is skipped, as in a
compressed file it
+ * would have to be uncompressed: no preview, no failure, and the stream
is not read on.
+ */
+ @Test
+ public void testLongBlockBeforeThePreview() throws Exception {
+ for (Header header : Header.values()) {
+ byte[] file = blend(header, true);
+ int rend = indexOf(file, "REND");
+ ByteBuffer buffer = ByteBuffer.wrap(file).order(header ==
Header.POINTER4_BIG_ENDIAN
+ ? ByteOrder.BIG_ENDIAN : ByteOrder.LITTLE_ENDIAN);
+ if (header == Header.LARGE_BLOCKS) {
+ buffer.putLong(rend + 16, Long.MAX_VALUE / 2);
+ } else {
+ buffer.putInt(rend + 4, 0x7fffffff);
+ }
+ assertNull(BlenderParser.readThumbnail(
+ BlendCompression.open(new
ByteArrayInputStream(gzip(file)))),
+ header.toString());
+ assertEquals(1, parse(gzip(file)).size());
+ }
+ }
+
+ @Test
+ public void testEmbeddedLimits() throws Exception {
+ ParseContext context = new ParseContext();
+ context.set(EmbeddedLimits.class, new EmbeddedLimits(10, false, 0,
false));
+ try (TikaInputStream tis = TikaInputStream.get(blend(Header.POINTER8,
true))) {
+ assertEquals(1, getRecursiveMetadata(tis, new Metadata(), context,
true).size());
+ }
+ }
+
+ @Test
+ public void testTruncatedFiles() throws Exception {
+ for (Header header : Header.values()) {
+ byte[] file = blend(header, true);
+ for (int length = 0; length < file.length; length++) {
+ byte[] cut = Arrays.copyOf(file, length);
+ try {
+ parseDirectly(cut);
+ } catch (TikaException | IOException e) {
+ // the ways out
+ }
+ }
+ }
+ }
+
+ @Test
+ public void testNotABlenderFile() throws Exception {
+ assertThrows(TikaException.class,
+ () ->
parseDirectly("BLENDEX-v279".getBytes(StandardCharsets.US_ASCII)));
+ assertThrows(TikaException.class,
+ () ->
parseDirectly("BLENDERxx-01v0500".getBytes(StandardCharsets.US_ASCII)));
+ assertThrows(TikaException.class,
+ () ->
parseDirectly("BLENDER17-02v0600".getBytes(StandardCharsets.US_ASCII)));
+ }
+
+ private static void assertPreview(Metadata thumbnail, String message)
throws IOException {
+
assertEquals(TikaCoreProperties.EmbeddedResourceType.THUMBNAIL.toString(),
+ thumbnail.get(TikaCoreProperties.EMBEDDED_RESOURCE_TYPE),
message);
+ assertEquals("thumbnail.png",
thumbnail.get(TikaCoreProperties.RESOURCE_NAME_KEY));
+ assertEquals("image/png", thumbnail.get(HttpHeaders.CONTENT_TYPE));
+ }
+
+ @Test
+ public void testPreviewPixels() throws Exception {
+ for (Header header : Header.values()) {
+ byte[] png = BlenderParser.readThumbnail(
+ new ByteArrayInputStream(blend(header, true)));
+ BufferedImage image = ImageIO.read(new ByteArrayInputStream(png));
+ assertEquals(3, image.getWidth(), header.toString());
+ assertEquals(2, image.getHeight());
+ // the top row is the one stored second, RGBA becomes ARGB
+ assertEquals(0x44112233, image.getRGB(2, 0));
+ assertEquals(0xff000000, image.getRGB(1, 0));
+ assertEquals(0xffff0000, image.getRGB(0, 1));
+ assertEquals(0x800000ff, image.getRGB(2, 1));
+ }
+ }
+
+ private List<Metadata> parse(byte[] file) throws Exception {
+ try (TikaInputStream tis = TikaInputStream.get(file)) {
+ return getRecursiveMetadata(tis, true);
+ }
+ }
+
+ private static void parseDirectly(byte[] file) throws Exception {
+ try (TikaInputStream tis = TikaInputStream.get(file)) {
+ new BlenderParser().parse(tis, new DefaultHandler(), new
Metadata(),
+ new ParseContext());
+ }
+ }
+
+ private static String detect(byte[] file, String name) throws IOException {
+ Metadata metadata = new Metadata();
+ if (name != null) {
+ metadata.set(TikaCoreProperties.RESOURCE_NAME_KEY, name);
+ }
+ try (TikaInputStream tis = TikaInputStream.get(file)) {
+ return new DefaultDetector().detect(tis, metadata, new
ParseContext()).toString();
+ }
+ }
+
+ /** A blend file with a REND block, the preview as a TEST block if asked
for, and ENDB. */
+ private static byte[] blend(Header header, boolean preview) {
+ boolean little = header != Header.POINTER4_BIG_ENDIAN;
+ ByteBuffer out = ByteBuffer.allocate(1024)
+ .order(little ? ByteOrder.LITTLE_ENDIAN :
ByteOrder.BIG_ENDIAN);
+ String magic = header == Header.POINTER4_BIG_ENDIAN ? "BLENDER_V279"
+ : header == Header.POINTER8 ? "BLENDER-v402" :
"BLENDER17-01v0500";
+ out.put(magic.getBytes(StandardCharsets.US_ASCII));
+ block(out, header, "REND", new byte[72]);
+ if (preview) {
+ ByteBuffer data = ByteBuffer.allocate(8 + 4 *
6).order(out.order());
+ data.putInt(3).putInt(2);
+ for (int[] row : BOTTOM_UP) {
+ for (int rgba : row) {
+
data.order(ByteOrder.BIG_ENDIAN).putInt(rgba).order(out.order());
+ }
+ }
+ block(out, header, "TEST", data.array());
+ }
+ block(out, header, "GLOB", new byte[16]);
+ block(out, header, "ENDB", new byte[0]);
+ return Arrays.copyOf(out.array(), out.position());
+ }
+
+ private static void block(ByteBuffer out, Header header, String code,
byte[] data) {
+ out.put(code.getBytes(StandardCharsets.US_ASCII));
+ switch (header) {
+ case POINTER4_BIG_ENDIAN:
+ out.putInt(data.length).putInt(0x1000).putInt(0).putInt(1);
+ break;
+ case POINTER8:
+
out.putInt(data.length).putLong(0x7fff0000L).putInt(0).putInt(1);
+ break;
+ default:
+
out.putInt(0).putLong(0x7fff0000L).putLong(data.length).putLong(1);
+ break;
+ }
+ out.put(data);
+ }
+
+ private static byte[] gzip(byte[] data) throws IOException {
+ ByteArrayOutputStream bytes = new ByteArrayOutputStream();
+ try (GZIPOutputStream gz = new GZIPOutputStream(bytes)) {
+ gz.write(data);
+ }
+ return bytes.toByteArray();
+ }
+
+ private static int indexOf(byte[] haystack, String needle) {
+ byte[] n = needle.getBytes(StandardCharsets.US_ASCII);
+ for (int i = 0; i + n.length <= haystack.length; i++) {
+ if (Arrays.equals(haystack, i, i + n.length, n, 0, n.length)) {
+ return i;
+ }
+ }
+ throw new AssertionError(needle);
+ }
+}
diff --git
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-pkg-module/src/main/java/org/apache/tika/detect/gzip/GZipSpecializationDetector.java
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-pkg-module/src/main/java/org/apache/tika/detect/gzip/GZipSpecializationDetector.java
index 1d697aa0b1..3b415502c1 100644
---
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-pkg-module/src/main/java/org/apache/tika/detect/gzip/GZipSpecializationDetector.java
+++
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-pkg-module/src/main/java/org/apache/tika/detect/gzip/GZipSpecializationDetector.java
@@ -44,6 +44,8 @@ public class GZipSpecializationDetector implements Detector {
public static MediaType ARC_GZ = MediaType.application("arc+gz");
+ public static MediaType BLENDER = MediaType.application("x-blender");
+
@Override
public MediaType detect(TikaInputStream tis, Metadata metadata,
ParseContext parseContext) throws IOException {
if (tis == null) {
@@ -94,6 +96,8 @@ public class GZipSpecializationDetector implements Detector {
return WARC_GZ;
} else if (s.startsWith("filedesc://")) {
return ARC_GZ;
+ } else if (s.startsWith("BLENDER")) {
+ return BLENDER;
}
return GZ;
}