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 0b2b09c823 TIKA-5042: Parse Canon CR3 raws, with their Exif data and 
previews (#3535)
0b2b09c823 is described below

commit 0b2b09c8236613a02f9ab32a52270fc0b45fb455
Author: Dominik Schmidt <[email protected]>
AuthorDate: Fri Nov 6 17:34:08 2026 +0100

    TIKA-5042: Parse Canon CR3 raws, with their Exif data and previews (#3535)
    
    * TIKA-5042: emit the Canon CR3 previews
---
 CHANGES.txt                                        |   6 +
 docs/modules/ROOT/pages/formats.adoc               |   3 +
 docs/modules/ROOT/partials/supported-formats.adoc  |   2 +
 .../tika/parser/image/Cr3AutoDetectTest.java       |  88 +++++
 .../org/apache/tika/parser/image/Cr3Parser.java    | 377 +++++++++++++++++++++
 .../apache/tika/parser/image/Cr3ParserTest.java    | 359 ++++++++++++++++++++
 .../src/test/resources/test-documents/testCR3.cr3  | Bin 0 -> 5896 bytes
 7 files changed, 835 insertions(+)

diff --git a/CHANGES.txt b/CHANGES.txt
index 406a6a186d..5c6977995f 100644
--- a/CHANGES.txt
+++ b/CHANGES.txt
@@ -81,6 +81,12 @@ Release 4.3.0 - ???
 
   EXTRACTION AND FORMATS
 
+   * Canon CR3 raws have a parser of their own, Cr3Parser in the image module, 
instead
+     of going through the MP4 parser: their Exif data (IFD0, Exif IFD, Canon 
maker note,
+     GPS) is extracted, the JPEG preview (PRVW, 1620x1080 on current bodies) 
is the
+     THUMBNAIL and the 160x120 THMB thumbnail is INLINE, so the thumbnail 
preset serves
+     a preview (TIKA-5042).
+
    * 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
diff --git a/docs/modules/ROOT/pages/formats.adoc 
b/docs/modules/ROOT/pages/formats.adoc
index f04e24cb70..0c0197b6c6 100644
--- a/docs/modules/ROOT/pages/formats.adoc
+++ b/docs/modules/ROOT/pages/formats.adoc
@@ -234,6 +234,9 @@ 
link:{api}/org/apache/tika/parser/image/TiffParser.html[TiffParser], which use
 the metadata-extractor library for Exif, IPTC and XMP; and from
 link:{api}/org/apache/tika/parser/image/RawTiffParser.html[RawTiffParser] for
 camera RAW files built on TIFF.
+link:{api}/org/apache/tika/parser/image/Cr3Parser.html[Cr3Parser] reads Canon 
CR3
+raw files, which are built on the ISO base media file format: their Exif data 
and
+their stored JPEG previews.
 link:{api}/org/apache/tika/parser/image/HeifParser.html[HeifParser] (HEIF/HEIC,
 AVIF),
 link:{api}/org/apache/tika/parser/image/JXLParser.html[JXLParser] (JPEG XL),
diff --git a/docs/modules/ROOT/partials/supported-formats.adoc 
b/docs/modules/ROOT/partials/supported-formats.adoc
index 5202459962..d7a8fec5eb 100644
--- a/docs/modules/ROOT/partials/supported-formats.adoc
+++ b/docs/modules/ROOT/partials/supported-formats.adoc
@@ -107,6 +107,8 @@
 * `org.apache.tika.parser.image.` 
link:{tika-javadoc-url}/org/apache/tika/parser/image/BPGParser.html[BPGParser]
 ** `image/bpg`
 ** `image/x-bpg`
+* `org.apache.tika.parser.image.` 
link:{tika-javadoc-url}/org/apache/tika/parser/image/Cr3Parser.html[Cr3Parser]
+** `image/x-canon-cr3`
 * `org.apache.tika.parser.image.` 
link:{tika-javadoc-url}/org/apache/tika/parser/image/HeifParser.html[HeifParser]
 ** `image/avif`
 ** `image/heic`
diff --git 
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-integration-tests/src/test/java/org/apache/tika/parser/image/Cr3AutoDetectTest.java
 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-integration-tests/src/test/java/org/apache/tika/parser/image/Cr3AutoDetectTest.java
new file mode 100644
index 0000000000..f4b0447936
--- /dev/null
+++ 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-integration-tests/src/test/java/org/apache/tika/parser/image/Cr3AutoDetectTest.java
@@ -0,0 +1,88 @@
+/*
+ * 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 java.awt.image.BufferedImage;
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.nio.ByteBuffer;
+import java.nio.charset.StandardCharsets;
+import java.util.Arrays;
+import java.util.HexFormat;
+import java.util.List;
+import javax.imageio.ImageIO;
+
+import org.junit.jupiter.api.Test;
+
+import org.apache.tika.TikaTest;
+import org.apache.tika.io.TikaInputStream;
+import org.apache.tika.metadata.HttpHeaders;
+import org.apache.tika.metadata.Metadata;
+import org.apache.tika.metadata.TikaCoreProperties;
+
+/** With every standard parser, a CR3 goes to Cr3Parser, not to the MP4 parser 
of its family. */
+public class Cr3AutoDetectTest extends TikaTest {
+
+    @Test
+    public void testAutoDetectParserPicksCr3Parser() throws Exception {
+        List<Metadata> list;
+        try (TikaInputStream tis = TikaInputStream.get(cr3())) {
+            list = getRecursiveMetadata(tis, true);
+        }
+        Metadata cr3 = list.get(0);
+        assertEquals("image/x-canon-cr3", cr3.get(HttpHeaders.CONTENT_TYPE));
+        assertContains(Cr3Parser.class.getName(),
+                
Arrays.asList(cr3.getValues(TikaCoreProperties.TIKA_PARSED_BY)));
+        assertEquals(2, list.size());
+        
assertEquals(TikaCoreProperties.EmbeddedResourceType.THUMBNAIL.toString(),
+                list.get(1).get(TikaCoreProperties.EMBEDDED_RESOURCE_TYPE));
+    }
+
+    /** ftyp crx, an empty moov and the Canon preview box with a PRVW record. 
*/
+    private static byte[] cr3() throws IOException {
+        byte[] jpeg = jpeg();
+        ByteArrayOutputStream prvw = new ByteArrayOutputStream();
+        prvw.writeBytes(new byte[4]);
+        prvw.writeBytes(ByteBuffer.allocate(8).putShort((short) 
1).putShort((short) 48)
+                .putShort((short) 32).putShort((short) 1).array());
+        prvw.writeBytes(ByteBuffer.allocate(4).putInt(jpeg.length).array());
+        prvw.writeBytes(jpeg);
+        ByteArrayOutputStream preview = new ByteArrayOutputStream();
+        
preview.writeBytes(HexFormat.of().parseHex("eaf42b5e1c984b88b9fbb7dc406e4d16"));
+        preview.writeBytes(new byte[]{0, 0, 0, 0, 0, 0, 0, 1});
+        preview.writeBytes(box("PRVW", prvw.toByteArray()));
+        ByteArrayOutputStream file = new ByteArrayOutputStream();
+        file.writeBytes(box("ftyp", "crx \0\0\0\1crx 
isom".getBytes(StandardCharsets.US_ASCII)));
+        file.writeBytes(box("moov", box("mvhd", new byte[100])));
+        file.writeBytes(box("uuid", preview.toByteArray()));
+        file.writeBytes(box("mdat", new byte[16]));
+        return file.toByteArray();
+    }
+
+    private static byte[] box(String type, byte[] payload) {
+        return ByteBuffer.allocate(8 + payload.length).putInt(8 + 
payload.length)
+                
.put(type.getBytes(StandardCharsets.US_ASCII)).put(payload).array();
+    }
+
+    private static byte[] jpeg() throws IOException {
+        ByteArrayOutputStream out = new ByteArrayOutputStream();
+        ImageIO.write(new BufferedImage(48, 32, BufferedImage.TYPE_INT_RGB), 
"jpg", out);
+        return out.toByteArray();
+    }
+}
diff --git 
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-image-module/src/main/java/org/apache/tika/parser/image/Cr3Parser.java
 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-image-module/src/main/java/org/apache/tika/parser/image/Cr3Parser.java
new file mode 100644
index 0000000000..bb711441e1
--- /dev/null
+++ 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-image-module/src/main/java/org/apache/tika/parser/image/Cr3Parser.java
@@ -0,0 +1,377 @@
+/*
+ * 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.io.IOException;
+import java.nio.ByteBuffer;
+import java.nio.channels.FileChannel;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Path;
+import java.nio.file.StandardOpenOption;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.Set;
+
+import com.drew.imaging.tiff.TiffProcessingException;
+import com.drew.imaging.tiff.TiffReader;
+import com.drew.imaging.tiff.TiffStandard;
+import com.drew.lang.ByteArrayReader;
+import com.drew.metadata.Directory;
+import com.drew.metadata.MetadataException;
+import com.drew.metadata.exif.ExifIFD0Directory;
+import com.drew.metadata.exif.ExifSubIFDDirectory;
+import com.drew.metadata.exif.ExifTiffHandler;
+import com.drew.metadata.exif.GpsDirectory;
+import com.drew.metadata.exif.makernotes.CanonMakernoteDirectory;
+import org.xml.sax.ContentHandler;
+import org.xml.sax.SAXException;
+
+import org.apache.tika.annotation.TikaComponent;
+import org.apache.tika.config.ConfigDeserializer;
+import org.apache.tika.config.JsonConfig;
+import org.apache.tika.exception.TikaException;
+import org.apache.tika.extractor.EmbeddedDocumentExtractor;
+import org.apache.tika.extractor.EmbeddedDocumentUtil;
+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.mime.MediaType;
+import org.apache.tika.parser.ParseContext;
+import org.apache.tika.parser.Parser;
+import org.apache.tika.parser.xmp.XmpExtractor;
+import org.apache.tika.sax.XHTMLContentHandler;
+
+/**
+ * Parser for Canon CR3 raw images. CR3 is built on the ISO base media file 
format,
+ * like HEIF, and keeps what a still image needs in Canon {@code uuid} boxes:
+ * <ul>
+ *   <li>{@code 85c0b687-820f-11e0-8111-f4ce462b6a48} in {@code moov} holds 
the Exif
+ *   data as four TIFF blocks, {@code CMT1} (IFD0), {@code CMT2} (Exif IFD),
+ *   {@code CMT3} (Canon maker note) and {@code CMT4} (GPS), and a {@code THMB}
+ *   record with a 160x120 JPEG, emitted as
+ *   {@link TikaCoreProperties.EmbeddedResourceType#INLINE}</li>
+ *   <li>{@code eaf42b5e-1c98-4b88-b9fb-b7dc406e4d16} at the top level holds, 
after
+ *   eight bytes, a {@code PRVW} record with the large JPEG preview (1620 
pixels wide
+ *   on current bodies), emitted as the
+ *   {@link TikaCoreProperties.EmbeddedResourceType#THUMBNAIL}</li>
+ *   <li>{@code be7acfcb-97a9-42e8-9c71-999491e3afac} at the top level holds 
an XMP packet,
+ *   with the rating given in the camera or in Canon's software</li>
+ * </ul>
+ * The {@code THMB} and {@code PRVW} records carry width and height and the 
JPEG
+ * length ahead of the JPEG, which starts 24 bytes into the record.
+ */
+@TikaComponent(name = "cr3-parser")
+public class Cr3Parser implements Parser {
+
+    private static final long serialVersionUID = 7130874255196421580L;
+
+    private static final Set<MediaType> SUPPORTED_TYPES =
+            Collections.singleton(MediaType.image("x-canon-cr3"));
+
+    private static final byte[] METADATA_UUID = 
uuid("85c0b687820f11e08111f4ce462b6a48");
+    private static final byte[] PREVIEW_UUID = 
uuid("eaf42b5e1c984b88b9fbb7dc406e4d16");
+    private static final byte[] XMP_UUID = 
uuid("be7acfcb97a942e89c71999491e3afac");
+    private static final int UUID_LENGTH = 16;
+    /** The preview box's children start after eight bytes of its own. */
+    private static final int PREVIEW_CHILDREN_OFFSET = 8;
+    /** From the start of a PRVW or THMB box to its JPEG: size, type and 
sixteen header bytes. */
+    private static final int JPEG_OFFSET = 24;
+    /** An Exif block above this size is not read; real ones are a few KB to 
some tens of KB. */
+    private static final int MAX_TIFF_BLOCK = 4 * 1024 * 1024;
+    /** A preview above this size is not read; real ones are some hundred KB. 
*/
+    private static final int MAX_PREVIEW = 64 * 1024 * 1024;
+    /** An XMP packet above this size is not read; Canon writes one of 64 KB, 
padding included. */
+    private static final int MAX_XMP = 1024 * 1024;
+
+    private final ImageMetadataConfig defaultConfig;
+
+    public Cr3Parser() {
+        this(new ImageMetadataConfig());
+    }
+
+    public Cr3Parser(ImageMetadataConfig config) {
+        this.defaultConfig = config;
+    }
+
+    public Cr3Parser(JsonConfig jsonConfig) {
+        this(ConfigDeserializer.buildConfig(jsonConfig, 
ImageMetadataConfig.class));
+    }
+
+    @Override
+    public Set<MediaType> getSupportedTypes(ParseContext context) {
+        return SUPPORTED_TYPES;
+    }
+
+    /** A JPEG record: where its JPEG starts, how long it is, and the size it 
declares. */
+    private record Jpeg(long offset, int length, int width, int height) {
+    }
+
+    @Override
+    public void parse(TikaInputStream tis, ContentHandler handler, Metadata 
metadata,
+                      ParseContext context) throws IOException, SAXException, 
TikaException {
+        Path path = tis.getPath();
+        com.drew.metadata.Metadata exif = new com.drew.metadata.Metadata();
+        Jpeg thumbnail = null;
+        Jpeg preview = null;
+        byte[] xmp = null;
+        try (FileChannel channel = FileChannel.open(path, 
StandardOpenOption.READ)) {
+            long length = channel.size();
+            long offset = 0;
+            while (offset + 8 <= length) {
+                Box box = Box.read(channel, offset, length);
+                if (box == null) {
+                    break;
+                }
+                if (box.is("moov")) {
+                    thumbnail = readMoov(channel, box, exif);
+                } else if (box.is("uuid") && box.hasUuid(channel, 
PREVIEW_UUID)) {
+                    preview = readRecord(channel, box.payload + UUID_LENGTH + 
PREVIEW_CHILDREN_OFFSET,
+                            box.end, "PRVW", 10, 16);
+                } else if (box.is("uuid") && box.hasUuid(channel, XMP_UUID)) {
+                    long xmpLength = box.end - box.payload - UUID_LENGTH;
+                    if (xmpLength > 0 && xmpLength <= MAX_XMP) {
+                        xmp = readBytes(channel, box.payload + UUID_LENGTH, 
(int) xmpLength);
+                    }
+                }
+                offset = box.end;
+            }
+
+            if (xmp != null) {
+                try {
+                    new XmpExtractor().extract(xmp, metadata, context);
+                } catch (SecurityException e) {
+                    throw e;
+                } catch (IOException | SAXException | TikaException | 
RuntimeException e) {
+                    EmbeddedDocumentUtil.recordException(e, metadata, context);
+                }
+            }
+            try {
+                new ImageMetadataExtractor(metadata,
+                        ImageMetadataConfig.resolve(context, "cr3-parser", 
defaultConfig))
+                        .handle(exif);
+            } catch (MetadataException e) {
+                throw new TikaException("Can't read the CR3 Exif data", e);
+            }
+
+            XHTMLContentHandler xhtml = new XHTMLContentHandler(handler, 
metadata, context);
+            xhtml.startDocument();
+            if (thumbnail != null) {
+                emit(channel, thumbnail, 
TikaCoreProperties.EmbeddedResourceType.INLINE, xhtml,
+                        context);
+            }
+            if (preview != null) {
+                emit(channel, preview, 
TikaCoreProperties.EmbeddedResourceType.THUMBNAIL, xhtml,
+                        context);
+            }
+            xhtml.endDocument();
+        }
+    }
+
+    /** Reads the Exif blocks of the Canon box in {@code moov} and returns its 
THMB record. */
+    private static Jpeg readMoov(FileChannel channel, Box moov, 
com.drew.metadata.Metadata exif)
+            throws IOException {
+        long offset = moov.payload;
+        while (offset + 8 <= moov.end) {
+            Box box = Box.read(channel, offset, moov.end);
+            if (box == null) {
+                return null;
+            }
+            if (box.is("uuid") && box.hasUuid(channel, METADATA_UUID)) {
+                Jpeg thumbnail = null;
+                long child = box.payload + UUID_LENGTH;
+                while (child + 8 <= box.end) {
+                    Box record = Box.read(channel, child, box.end);
+                    if (record == null) {
+                        break;
+                    }
+                    switch (record.type) {
+                        case "CMT1" -> readExif(channel, record, exif, 
ExifIFD0Directory.class);
+                        case "CMT2" -> readExif(channel, record, exif, 
ExifSubIFDDirectory.class);
+                        case "CMT3" -> readExif(channel, record, exif, 
CanonMakernoteDirectory.class);
+                        case "CMT4" -> readExif(channel, record, exif, 
GpsDirectory.class);
+                        case "THMB" -> thumbnail = readRecord(channel, 
record.offset, box.end,
+                                "THMB", 8, 12);
+                        default -> {
+                        }
+                    }
+                    child = record.end;
+                }
+                return thumbnail;
+            }
+            offset = box.end;
+        }
+        return null;
+    }
+
+    private static void readExif(FileChannel channel, Box record, 
com.drew.metadata.Metadata exif,
+                                 Class<? extends Directory> firstDirectory) 
throws IOException {
+        long size = record.end - record.payload;
+        if (size <= 8 || size > MAX_TIFF_BLOCK) {
+            return;
+        }
+        byte[] tiff = readBytes(channel, record.payload, (int) size);
+        try {
+            new TiffReader().processTiff(new ByteArrayReader(tiff),
+                    new StartingDirectoryHandler(exif, firstDirectory), 0);
+        } catch (TiffProcessingException | IOException e) {
+            //a block that cannot be read leaves the others; 
metadata-extractor records
+            //what it found up to there
+        }
+    }
+
+    /**
+     * Finds the record of the given type from {@code start} on and returns 
where its
+     * JPEG is; {@code sizeOffset} and {@code lengthOffset} count from the 
record's type.
+     */
+    private static Jpeg readRecord(FileChannel channel, long start, long end, 
String type,
+                                   int sizeOffset, int lengthOffset) throws 
IOException {
+        long offset = start;
+        while (offset + 8 <= end) {
+            Box box = Box.read(channel, offset, end);
+            if (box == null) {
+                return null;
+            }
+            if (box.is(type)) {
+                if (box.end - box.offset < JPEG_OFFSET) {
+                    return null;
+                }
+                ByteBuffer header = ByteBuffer.wrap(readBytes(channel, 
box.offset + 4, JPEG_OFFSET - 4));
+                long jpegLength = header.getInt(lengthOffset) & 0xFFFFFFFFL;
+                long jpeg = box.offset + JPEG_OFFSET;
+                if (jpegLength < 2 || jpegLength > box.end - jpeg || 
jpegLength > MAX_PREVIEW) {
+                    return null;
+                }
+                return new Jpeg(jpeg, (int) jpegLength, 
header.getShort(sizeOffset) & 0xFFFF,
+                        header.getShort(sizeOffset + 2) & 0xFFFF);
+            }
+            offset = box.end;
+        }
+        return null;
+    }
+
+    private static void emit(FileChannel channel, Jpeg jpeg,
+                             TikaCoreProperties.EmbeddedResourceType type,
+                             XHTMLContentHandler xhtml, ParseContext context)
+            throws IOException, SAXException {
+        byte[] bytes = readBytes(channel, jpeg.offset(), jpeg.length());
+        if ((bytes[0] & 0xFF) != 0xFF || (bytes[1] & 0xFF) != 0xD8) {
+            return;
+        }
+        Metadata metadata = Metadata.newInstance(context);
+        metadata.set(HttpHeaders.CONTENT_TYPE, "image/jpeg");
+        metadata.set(TikaCoreProperties.EMBEDDED_RESOURCE_TYPE, type.name());
+        EmbeddedDocumentUtil.setGeneratedResourceName(metadata,
+                type == TikaCoreProperties.EmbeddedResourceType.THUMBNAIL
+                        ? EmbeddedDocumentUtil.EmbeddedResourcePrefix.THUMBNAIL
+                        : EmbeddedDocumentUtil.EmbeddedResourcePrefix.IMAGE, 
0, "image/jpeg");
+        metadata.set(TIFF.IMAGE_WIDTH, jpeg.width());
+        metadata.set(TIFF.IMAGE_LENGTH, jpeg.height());
+        EmbeddedDocumentExtractor extractor =
+                EmbeddedDocumentUtil.getEmbeddedDocumentExtractor(context);
+        if (!extractor.shouldParseEmbedded(metadata, context)) {
+            return;
+        }
+        try (TikaInputStream image = TikaInputStream.get(bytes)) {
+            extractor.parseEmbedded(image, xhtml, metadata, context, true);
+        }
+    }
+
+    private static byte[] readBytes(FileChannel channel, long offset, int 
length) throws IOException {
+        ByteBuffer buffer = ByteBuffer.allocate(length);
+        while (buffer.hasRemaining()) {
+            if (channel.read(buffer, offset + buffer.position()) < 0) {
+                throw new IOException("unexpected end of the CR3 file");
+            }
+        }
+        return buffer.array();
+    }
+
+    private static byte[] uuid(String hex) {
+        byte[] bytes = new byte[UUID_LENGTH];
+        for (int i = 0; i < UUID_LENGTH; i++) {
+            bytes[i] = (byte) Integer.parseInt(hex.substring(2 * i, 2 * i + 
2), 16);
+        }
+        return bytes;
+    }
+
+    /** An ISO base media box: where it starts, where its payload starts and 
where it ends. */
+    private static final class Box {
+        final long offset;
+        final long payload;
+        final long end;
+        final String type;
+
+        private Box(long offset, long payload, long end, String type) {
+            this.offset = offset;
+            this.payload = payload;
+            this.end = end;
+            this.type = type;
+        }
+
+        /** The box at {@code offset}, or null when its header does not fit 
before {@code limit}. */
+        static Box read(FileChannel channel, long offset, long limit) throws 
IOException {
+            if (offset + 8 > limit) {
+                return null;
+            }
+            ByteBuffer header = ByteBuffer.wrap(readBytes(channel, offset, 8));
+            long size = header.getInt(0) & 0xFFFFFFFFL;
+            String type = new String(header.array(), 4, 4, 
StandardCharsets.ISO_8859_1);
+            long payload = offset + 8;
+            if (size == 1) {
+                if (offset + 16 > limit) {
+                    return null;
+                }
+                size = ByteBuffer.wrap(readBytes(channel, offset + 8, 
8)).getLong();
+                payload = offset + 16;
+            } else if (size == 0) {
+                size = limit - offset;
+            }
+            if (size < payload - offset || size > limit - offset) {
+                return null;
+            }
+            return new Box(offset, payload, offset + size, type);
+        }
+
+        boolean is(String boxType) {
+            return type.equals(boxType);
+        }
+
+        boolean hasUuid(FileChannel channel, byte[] uuid) throws IOException {
+            return end - payload >= UUID_LENGTH
+                    && Arrays.equals(readBytes(channel, payload, UUID_LENGTH), 
uuid);
+        }
+    }
+
+    /** Starts a TIFF block in the given directory instead of IFD0. */
+    private static final class StartingDirectoryHandler extends 
ExifTiffHandler {
+        private final Class<? extends Directory> firstDirectory;
+
+        StartingDirectoryHandler(com.drew.metadata.Metadata metadata,
+                                 Class<? extends Directory> firstDirectory) {
+            super(metadata, null, 0);
+            this.firstDirectory = firstDirectory;
+        }
+
+        @Override
+        public TiffStandard processTiffMarker(int marker) {
+            pushDirectory(firstDirectory);
+            return TiffStandard.TIFF;
+        }
+    }
+}
diff --git 
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-image-module/src/test/java/org/apache/tika/parser/image/Cr3ParserTest.java
 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-image-module/src/test/java/org/apache/tika/parser/image/Cr3ParserTest.java
new file mode 100644
index 0000000000..fca9882cca
--- /dev/null
+++ 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-image-module/src/test/java/org/apache/tika/parser/image/Cr3ParserTest.java
@@ -0,0 +1,359 @@
+/*
+ * 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.assertArrayEquals;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+
+import java.awt.image.BufferedImage;
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.nio.ByteBuffer;
+import java.nio.ByteOrder;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+import javax.imageio.ImageIO;
+
+import org.junit.jupiter.api.Test;
+import org.xml.sax.ContentHandler;
+
+import org.apache.tika.TikaTest;
+import org.apache.tika.extractor.EmbeddedDocumentExtractor;
+import org.apache.tika.io.TikaInputStream;
+import org.apache.tika.metadata.Geographic;
+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.XMP;
+import org.apache.tika.parser.ParseContext;
+import org.apache.tika.sax.BodyContentHandler;
+
+/**
+ * Canon CR3, on a file built here with the box layout of a real CR3: the Exif 
blocks
+ * and a {@code THMB} record in the Canon {@code uuid} box inside {@code 
moov}, a
+ * {@code PRVW} record in the Canon {@code uuid} box at the top level.
+ */
+public class Cr3ParserTest extends TikaTest {
+
+    private static final String METADATA_UUID = 
"85c0b687820f11e08111f4ce462b6a48";
+    private static final String PREVIEW_UUID = 
"eaf42b5e1c984b88b9fbb7dc406e4d16";
+    private static final String XMP_UUID = "be7acfcb97a942e89c71999491e3afac";
+
+    private static final short ASCII = 2;
+    private static final short SHORT = 3;
+    private static final short RATIONAL = 5;
+
+    @Test
+    public void testExif() throws Exception {
+        Metadata metadata = new Metadata();
+        parse(cr3(jpeg(16, 12), jpeg(48, 32)), metadata, new ArrayList<>(), 
new ArrayList<>());
+        assertEquals("Canon", metadata.get(TIFF.EQUIPMENT_MAKE));
+        assertEquals("Canon EOS R7", metadata.get(TIFF.EQUIPMENT_MODEL));
+        assertEquals("0.004", metadata.get(TIFF.EXPOSURE_TIME));
+        assertEquals("100", metadata.get(TIFF.ISO_SPEED_RATINGS));
+        assertEquals("48.5", metadata.get(Geographic.LATITUDE));
+        assertEquals("11.25", metadata.get(Geographic.LONGITUDE));
+        assertEquals("Firmware Version 1.0.1", metadata.get("img:Firmware 
Version"),
+                "the maker note block is read as Canon's maker note");
+    }
+
+    @Test
+    public void testPreviewIsThumbnailAndThmbIsInline() throws Exception {
+        List<Metadata> embedded = new ArrayList<>();
+        parse(cr3(jpeg(16, 12), jpeg(48, 32)), new Metadata(), embedded, new 
ArrayList<>());
+        assertEquals(2, embedded.size());
+        assertEquals("INLINE", 
embedded.get(0).get(TikaCoreProperties.EMBEDDED_RESOURCE_TYPE));
+        assertEquals("16", embedded.get(0).get(TIFF.IMAGE_WIDTH));
+        assertEquals("THUMBNAIL", 
embedded.get(1).get(TikaCoreProperties.EMBEDDED_RESOURCE_TYPE));
+        assertEquals("48", embedded.get(1).get(TIFF.IMAGE_WIDTH));
+        assertEquals("32", embedded.get(1).get(TIFF.IMAGE_LENGTH));
+        assertEquals("image/jpeg", 
embedded.get(1).get(HttpHeaders.CONTENT_TYPE));
+        assertEquals("image-0.jpg", 
embedded.get(0).get(TikaCoreProperties.RESOURCE_NAME_KEY));
+        assertEquals("thumbnail-0.jpg", 
embedded.get(1).get(TikaCoreProperties.RESOURCE_NAME_KEY));
+    }
+
+    @Test
+    public void testJpegBytesAreExact() throws Exception {
+        byte[] prvw = jpeg(48, 32);
+        List<byte[]> bytes = new ArrayList<>();
+        parse(cr3(jpeg(16, 12), prvw), new Metadata(), new ArrayList<>(), 
bytes);
+        assertArrayEquals(prvw, bytes.get(1));
+    }
+
+    @Test
+    public void testLengthBeyondTheBoxIsIgnored() throws Exception {
+        byte[] file = cr3(jpeg(16, 12), jpeg(48, 32));
+        int prvw = indexOf(file, "PRVW".getBytes(StandardCharsets.US_ASCII));
+        // within the 64 MB cap, but running past the box into mdat, where the 
bytes still
+        // start like the JPEG
+        ByteBuffer.wrap(file).putInt(prvw + 16, jpeg(48, 32).length + 10);
+        List<Metadata> embedded = new ArrayList<>();
+        Metadata metadata = new Metadata();
+        parse(file, metadata, embedded, new ArrayList<>());
+        assertEquals(1, embedded.size());
+        assertEquals("INLINE", 
embedded.get(0).get(TikaCoreProperties.EMBEDDED_RESOURCE_TYPE));
+        assertEquals("Canon EOS R7", metadata.get(TIFF.EQUIPMENT_MODEL));
+    }
+
+    /** The XMP box as the camera writes it: a packet with the rating, padded 
to 64 KB. */
+    @Test
+    public void testXmpRating() throws Exception {
+        ByteArrayOutputStream file = new ByteArrayOutputStream();
+        file.write(cr3(jpeg(16, 12), jpeg(48, 32)));
+        file.write(xmpBox(4, 65536));
+        Metadata metadata = new Metadata();
+        List<Metadata> embedded = new ArrayList<>();
+        parse(file.toByteArray(), metadata, embedded, new ArrayList<>());
+        assertEquals("4", metadata.get(XMP.RATING));
+        assertEquals("Canon EOS R7", metadata.get(TIFF.EQUIPMENT_MODEL));
+        assertEquals(2, embedded.size());
+    }
+
+    @Test
+    public void testBadXmpKeepsTheRest() throws Exception {
+        ByteArrayOutputStream xmpUuid = new ByteArrayOutputStream();
+        xmpUuid.write(hex(XMP_UUID));
+        xmpUuid.write(ascii("<x:xmpmeta xmlns:x=\"adobe:ns:meta/\"><rdf:RDF"));
+        ByteArrayOutputStream file = new ByteArrayOutputStream();
+        file.write(cr3(jpeg(16, 12), jpeg(48, 32)));
+        file.write(box("uuid", xmpUuid.toByteArray()));
+        Metadata metadata = new Metadata();
+        List<Metadata> embedded = new ArrayList<>();
+        parse(file.toByteArray(), metadata, embedded, new ArrayList<>());
+        assertNull(metadata.get(XMP.RATING));
+        assertEquals("Canon EOS R7", metadata.get(TIFF.EQUIPMENT_MODEL));
+        assertEquals(2, embedded.size());
+    }
+
+    /**
+     * testCR3.cr3 is the file these tests build, as cr3(), with colored 
previews and an XMP
+     * box rated 3, saved for trying the parser by hand.
+     */
+    @Test
+    public void testFixture() throws Exception {
+        Metadata metadata = new Metadata();
+        List<Metadata> embedded = new ArrayList<>();
+        try (TikaInputStream tis = 
getResourceAsStream("/test-documents/testCR3.cr3")) {
+            parse(tis.readAllBytes(), metadata, embedded, new ArrayList<>());
+        }
+        assertEquals("Canon", metadata.get(TIFF.EQUIPMENT_MAKE));
+        assertEquals("Canon EOS R7", metadata.get(TIFF.EQUIPMENT_MODEL));
+        assertEquals("0.004", metadata.get(TIFF.EXPOSURE_TIME));
+        assertEquals("48.5", metadata.get(Geographic.LATITUDE));
+        assertEquals("Firmware Version 1.0.1", metadata.get("img:Firmware 
Version"));
+        assertEquals("3", metadata.get(XMP.RATING));
+        assertEquals(2, embedded.size());
+        assertEquals("INLINE", 
embedded.get(0).get(TikaCoreProperties.EMBEDDED_RESOURCE_TYPE));
+        assertEquals("THUMBNAIL", 
embedded.get(1).get(TikaCoreProperties.EMBEDDED_RESOURCE_TYPE));
+        assertEquals("48", embedded.get(1).get(TIFF.IMAGE_WIDTH));
+    }
+
+    @Test
+    public void testNoCanonBoxes() throws Exception {
+        ByteArrayOutputStream file = new ByteArrayOutputStream();
+        file.write(box("ftyp", ascii("crx \0\0\0\1crx isom")));
+        file.write(box("moov", box("mvhd", new byte[100])));
+        List<Metadata> embedded = new ArrayList<>();
+        Metadata metadata = new Metadata();
+        parse(file.toByteArray(), metadata, embedded, new ArrayList<>());
+        assertEquals(0, embedded.size());
+        assertNull(metadata.get(TIFF.EQUIPMENT_MAKE));
+    }
+
+    private void parse(byte[] file, Metadata metadata, List<Metadata> 
embedded, List<byte[]> bytes)
+            throws Exception {
+        ParseContext context = new ParseContext();
+        context.set(EmbeddedDocumentExtractor.class, new 
EmbeddedDocumentExtractor() {
+            @Override
+            public boolean shouldParseEmbedded(Metadata m, ParseContext ctx) {
+                return true;
+            }
+
+            @Override
+            public void parseEmbedded(TikaInputStream stream, ContentHandler 
handler, Metadata m,
+                                      ParseContext ctx, boolean outputHtml) 
throws IOException {
+                embedded.add(m);
+                bytes.add(stream.readAllBytes());
+            }
+        });
+        try (TikaInputStream tis = TikaInputStream.get(file)) {
+            new Cr3Parser().parse(tis, new BodyContentHandler(), metadata, 
context);
+        }
+    }
+
+    private static byte[] cr3(byte[] thmb, byte[] prvw) throws IOException {
+        ByteArrayOutputStream thmbBox = new ByteArrayOutputStream();
+        thmbBox.write(new byte[4]);
+        thmbBox.write(shorts(16, 12));
+        thmbBox.write(ints(thmb.length));
+        thmbBox.write(new byte[]{0, 1, 0, 0});
+        thmbBox.write(thmb);
+        ByteArrayOutputStream metadataUuid = new ByteArrayOutputStream();
+        metadataUuid.write(hex(METADATA_UUID));
+        metadataUuid.write(box("CNCV", 
ascii("CanonCR3_001/01.09.00/00.00.00")));
+        metadataUuid.write(box("CMT1", tiff(new Entry(0x010F, ASCII, 
ascii("Canon\0")),
+                new Entry(0x0110, ASCII, ascii("Canon EOS R7\0")))));
+        metadataUuid.write(box("CMT2", tiff(new Entry(0x829A, RATIONAL, 
rationals(1, 250)),
+                new Entry(0x8827, SHORT, 
le(ByteBuffer.allocate(2).putShort((short) 100))))));
+        metadataUuid.write(box("CMT3", tiff(new Entry(0x0007, ASCII,
+                ascii("Firmware Version 1.0.1\0")))));
+        metadataUuid.write(box("CMT4", tiff(new Entry(0x0001, ASCII, 
ascii("N\0")),
+                new Entry(0x0002, RATIONAL, rationals(48, 1, 30, 1, 0, 1)),
+                new Entry(0x0003, ASCII, ascii("E\0")),
+                new Entry(0x0004, RATIONAL, rationals(11, 1, 15, 1, 0, 1)))));
+        metadataUuid.write(box("THMB", thmbBox.toByteArray()));
+
+        ByteArrayOutputStream prvwBox = new ByteArrayOutputStream();
+        prvwBox.write(new byte[4]);
+        prvwBox.write(shorts(1, 48, 32, 1));
+        prvwBox.write(ints(prvw.length));
+        prvwBox.write(prvw);
+        ByteArrayOutputStream previewUuid = new ByteArrayOutputStream();
+        previewUuid.write(hex(PREVIEW_UUID));
+        previewUuid.write(new byte[]{0, 0, 0, 0, 0, 0, 0, 1});
+        previewUuid.write(box("PRVW", prvwBox.toByteArray()));
+
+        ByteArrayOutputStream file = new ByteArrayOutputStream();
+        file.write(box("ftyp", ascii("crx \0\0\0\1crx isom")));
+        file.write(box("moov", box("uuid", metadataUuid.toByteArray())));
+        file.write(box("uuid", previewUuid.toByteArray()));
+        file.write(box("mdat", new byte[16]));
+        return file.toByteArray();
+    }
+
+    private record Entry(int tag, short type, byte[] value) {
+        int count() {
+            return type == ASCII ? value.length : type == SHORT ? value.length 
/ 2 : value.length / 8;
+        }
+    }
+
+    /** A little-endian TIFF block with one IFD; values that do not fit four 
bytes follow it. */
+    private static byte[] tiff(Entry... entries) {
+        int ifdSize = 2 + 12 * entries.length + 4;
+        int dataOffset = 8 + ifdSize;
+        ByteBuffer out = ByteBuffer.allocate(dataOffset + 
256).order(ByteOrder.LITTLE_ENDIAN);
+        out.put(new byte[]{'I', 'I', 42, 0}).putInt(8).putShort((short) 
entries.length);
+        ByteArrayOutputStream data = new ByteArrayOutputStream();
+        for (Entry e : entries) {
+            out.putShort((short) e.tag()).putShort(e.type()).putInt(e.count());
+            if (e.value().length <= 4) {
+                out.put(Arrays.copyOf(e.value(), 4));
+            } else {
+                out.putInt(dataOffset + data.size());
+                data.writeBytes(e.value());
+            }
+        }
+        out.putInt(0).put(data.toByteArray());
+        return Arrays.copyOf(out.array(), out.position());
+    }
+
+    private static byte[] rationals(int... values) {
+        ByteBuffer b = ByteBuffer.allocate(4 * 
values.length).order(ByteOrder.LITTLE_ENDIAN);
+        for (int v : values) {
+            b.putInt(v);
+        }
+        return b.array();
+    }
+
+    private static byte[] le(ByteBuffer b) {
+        byte[] a = b.array();
+        return new byte[]{a[1], a[0]};
+    }
+
+    private static byte[] box(String type, byte[] payload) {
+        return ByteBuffer.allocate(8 + payload.length).putInt(8 + 
payload.length)
+                .put(ascii(type)).put(payload).array();
+    }
+
+    private static byte[] jpeg(int width, int height) throws IOException {
+        return jpeg(width, height, 0x000000);
+    }
+
+    private static byte[] jpeg(int width, int height, int rgb) throws 
IOException {
+        BufferedImage image = new BufferedImage(width, height, 
BufferedImage.TYPE_INT_RGB);
+        for (int y = 0; y < height; y++) {
+            for (int x = 0; x < width; x++) {
+                image.setRGB(x, y, rgb);
+            }
+        }
+        ByteArrayOutputStream out = new ByteArrayOutputStream();
+        ImageIO.write(image, "jpeg", out);
+        return out.toByteArray();
+    }
+
+    /**
+     * The XMP box as the camera writes it: a packet with the rating, padded 
with spaces so
+     * the box holds the given number of bytes after its UUID.
+     */
+    private static byte[] xmpBox(int rating, int length) throws IOException {
+        String packet = "<?xpacket begin='\uFEFF' 
id='W5M0MpCehiHzreSzNTczkc9d'?>"
+                + "<x:xmpmeta xmlns:x=\"adobe:ns:meta/\"><rdf:RDF "
+                + "xmlns:rdf=\"http://www.w3.org/1999/02/22-rdf-syntax-ns#\";>"
+                + "<rdf:Description rdf:about=\"\" 
xmlns:xmp=\"http://ns.adobe.com/xap/1.0/\";>"
+                + "<xmp:Rating>" + rating + 
"</xmp:Rating></rdf:Description></rdf:RDF></x:xmpmeta>";
+        byte[] end = ascii("<?xpacket end='w'?>");
+        byte[] bytes = packet.getBytes(StandardCharsets.UTF_8);
+        byte[] padded = Arrays.copyOf(bytes, length - end.length);
+        Arrays.fill(padded, bytes.length, padded.length, (byte) ' ');
+        ByteArrayOutputStream uuid = new ByteArrayOutputStream();
+        uuid.write(hex(XMP_UUID));
+        uuid.write(padded);
+        uuid.write(end);
+        return box("uuid", uuid.toByteArray());
+    }
+
+    private static byte[] shorts(int... values) {
+        ByteBuffer b = ByteBuffer.allocate(2 * values.length);
+        for (int v : values) {
+            b.putShort((short) v);
+        }
+        return b.array();
+    }
+
+    private static byte[] ints(int value) {
+        return ByteBuffer.allocate(4).putInt(value).array();
+    }
+
+    private static byte[] ascii(String s) {
+        return s.getBytes(StandardCharsets.US_ASCII);
+    }
+
+    private static byte[] hex(String hex) {
+        byte[] bytes = new byte[hex.length() / 2];
+        for (int i = 0; i < bytes.length; i++) {
+            bytes[i] = (byte) Integer.parseInt(hex.substring(2 * i, 2 * i + 
2), 16);
+        }
+        return bytes;
+    }
+
+    private static int indexOf(byte[] data, byte[] needle) {
+        outer:
+        for (int i = 0; i <= data.length - needle.length; i++) {
+            for (int j = 0; j < needle.length; j++) {
+                if (data[i + j] != needle[j]) {
+                    continue outer;
+                }
+            }
+            return i;
+        }
+        return -1;
+    }
+}
diff --git 
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-image-module/src/test/resources/test-documents/testCR3.cr3
 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-image-module/src/test/resources/test-documents/testCR3.cr3
new file mode 100644
index 0000000000..68ecb32f9f
Binary files /dev/null and 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-image-module/src/test/resources/test-documents/testCR3.cr3
 differ

Reply via email to