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 cca14779ab TIKA-4850: Emit audio cover art as a THUMBNAIL embedded
document (#3090)
cca14779ab is described below
commit cca14779aba75ea1a5a985a6b47dc00228983921
Author: Dominik Schmidt <[email protected]>
AuthorDate: Tue Sep 1 13:01:27 2026 +0200
TIKA-4850: Emit audio cover art as a THUMBNAIL embedded document (#3090)
---
CHANGES.txt | 8 ++
.../org/apache/tika/parser/audio/CoverArt.java | 155 ++++++++++++++++++++
.../java/org/apache/tika/parser/mp3/Mp3Parser.java | 36 ++---
.../apache/tika/parser/mp4/TikaMp4BoxHandler.java | 5 +-
.../tika/parser/mp4/boxes/TikaUserDataBox.java | 27 +++-
.../org/apache/tika/parser/ogg/FlacParser.java | 42 +++---
.../org/apache/tika/parser/ogg/OggAudioParser.java | 158 ++++++++++-----------
.../org/apache/tika/parser/ogg/OpusParser.java | 3 +-
.../org/apache/tika/parser/ogg/SpeexParser.java | 3 +-
.../org/apache/tika/parser/ogg/TheoraParser.java | 4 +-
.../org/apache/tika/parser/ogg/VorbisParser.java | 3 +-
.../org/apache/tika/parser/audio/CoverArtTest.java | 59 ++++++++
.../org/apache/tika/parser/mp3/Mp3ParserTest.java | 72 +++++++++-
.../org/apache/tika/parser/mp4/MP4ParserTest.java | 46 +++++-
.../org/apache/tika/parser/ogg/FlacParserTest.java | 85 ++++++++++-
.../apache/tika/parser/ogg/OggAudioParserTest.java | 72 +++++++---
.../apache/tika/parser/ogg/VorbisParserTest.java | 6 +-
.../testFLAC_commentAndNativePicture.flac | Bin 0 -> 10833 bytes
18 files changed, 625 insertions(+), 159 deletions(-)
diff --git a/CHANGES.txt b/CHANGES.txt
index 664e3ebb98..572e357624 100644
--- a/CHANGES.txt
+++ b/CHANGES.txt
@@ -1,5 +1,13 @@
Release 4.1.0 - unreleased
+ * Audio cover art is emitted as a THUMBNAIL embedded document, like the
+ preview image of the document container formats: the front cover (ID3
+ APIC and FLAC/Vorbis picture type 3), else the first picture of type
+ "Other" or unknown, else the first picture, and the first covr image
+ of an MP4. Further pictures
+ stay INLINE. Clients that looked for cover art as INLINE need to
+ accept THUMBNAIL as well (TIKA-4850).
+
* tika-grpc resolves its plugin-roots fallback against the install
layout via DefaultPluginsDir instead of a working-directory-relative
pf4j default, and a WARN names the resolved directory when no plugins
diff --git
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-audiovideo-module/src/main/java/org/apache/tika/parser/audio/CoverArt.java
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-audiovideo-module/src/main/java/org/apache/tika/parser/audio/CoverArt.java
new file mode 100644
index 0000000000..ebdc8620c2
--- /dev/null
+++
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-audiovideo-module/src/main/java/org/apache/tika/parser/audio/CoverArt.java
@@ -0,0 +1,155 @@
+/*
+ * 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.audio;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.List;
+
+import org.xml.sax.SAXException;
+
+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.TikaCoreProperties;
+import org.apache.tika.parser.ParseContext;
+import org.apache.tika.parser.mp3.ID3Tags;
+import org.apache.tika.sax.XHTMLContentHandler;
+
+/**
+ * Picks the picture that stands for an audio file among its embedded
+ * pictures and sends them all to the embedded document extractor. That
+ * picture is emitted as a
+ * {@link TikaCoreProperties.EmbeddedResourceType#THUMBNAIL}, like the
+ * preview image of the document container formats, so a client can find
+ * the representative image of any file the same way; the other pictures
+ * are {@link TikaCoreProperties.EmbeddedResourceType#INLINE}. See TIKA-4850.
+ */
+public final class CoverArt {
+
+ /**
+ * The ID3v2 APIC picture type of the front cover, shared by the FLAC
+ * and Vorbis picture blocks.
+ */
+ public static final int FRONT_COVER = 3;
+
+ /**
+ * The ID3v2 APIC picture type "Other". Many taggers store the main
+ * cover art with this type instead of marking it a front cover.
+ */
+ public static final int OTHER = 0;
+
+ private CoverArt() {
+ }
+
+ /**
+ * One embedded picture of an audio file. The type is the ID3v2 APIC
+ * picture type, shared by the FLAC and Vorbis picture blocks; mime type
+ * and description may be null or empty.
+ */
+ public record Picture(int type, String mimeType, String description,
byte[] data) {
+
+ /**
+ * The type as {@link #thumbnailIndex(List)} sees it: a value beyond
+ * the ID3 picture type table counts as unknown.
+ */
+ int normalizedType() {
+ return type >= ID3Tags.PICTURE_TYPES.length ? -1 : type;
+ }
+ }
+
+ /**
+ * Returns the index of the picture to mark as the thumbnail: the first
+ * front cover; else the first picture whose type is "Other" or unknown,
+ * which is where taggers put the main art when they do not classify it,
+ * rather than e.g. a back cover or a leaflet that happens to come first;
+ * else the first picture.
+ *
+ * @param pictureTypes the picture types in file order; a negative value
+ * for a picture whose type is unknown
+ * @return the index, or -1 if there are no pictures
+ */
+ public static int thumbnailIndex(List<Integer> pictureTypes) {
+ if (pictureTypes.isEmpty()) {
+ return -1;
+ }
+ int front = pictureTypes.indexOf(FRONT_COVER);
+ if (front >= 0) {
+ return front;
+ }
+ for (int i = 0; i < pictureTypes.size(); i++) {
+ if (pictureTypes.get(i) <= OTHER) {
+ return i;
+ }
+ }
+ return 0;
+ }
+
+ /**
+ * The resource type of the picture at the given index.
+ */
+ public static TikaCoreProperties.EmbeddedResourceType resourceType(int
index,
+ int
thumbnailIndex) {
+ return index == thumbnailIndex ?
TikaCoreProperties.EmbeddedResourceType.THUMBNAIL
+ : TikaCoreProperties.EmbeddedResourceType.INLINE;
+ }
+
+ /**
+ * Sends the pictures of one audio file to the embedded document
+ * extractor: the one {@link #thumbnailIndex(List)} picks as the
+ * THUMBNAIL, the others as INLINE pictures. The pictures only become
+ * embedded documents, no metadata is recorded on the audio document
+ * itself. Call once per file, with all of its pictures, so exactly one
+ * of them is the thumbnail.
+ */
+ public static void extractPictures(List<Picture> pictures,
XHTMLContentHandler xhtml,
+ ParseContext context) throws
IOException, SAXException {
+ if (pictures.isEmpty()) {
+ return;
+ }
+ List<Integer> pictureTypes = new ArrayList<>();
+ for (Picture picture : pictures) {
+ pictureTypes.add(picture.normalizedType());
+ }
+ int thumbnailIndex = thumbnailIndex(pictureTypes);
+ EmbeddedDocumentExtractor extractor =
+ EmbeddedDocumentUtil.getEmbeddedDocumentExtractor(context);
+ for (int i = 0; i < pictures.size(); i++) {
+ Picture picture = pictures.get(i);
+ Metadata pictureMetadata = Metadata.newInstance(context);
+ pictureMetadata.set(TikaCoreProperties.EMBEDDED_RESOURCE_TYPE,
+ resourceType(i, thumbnailIndex).name());
+ if (picture.mimeType() != null && !picture.mimeType().isEmpty()) {
+ pictureMetadata.set(HttpHeaders.CONTENT_TYPE,
picture.mimeType());
+ }
+ if (picture.description() != null &&
!picture.description().isEmpty()) {
+ pictureMetadata.set(TikaCoreProperties.TITLE,
picture.description());
+ }
+ if (picture.type() >= 0 && picture.type() <
ID3Tags.PICTURE_TYPES.length) {
+ pictureMetadata.set(TikaCoreProperties.DESCRIPTION,
+ ID3Tags.PICTURE_TYPES[picture.type()]);
+ }
+ if (extractor.shouldParseEmbedded(pictureMetadata, context)) {
+ try (TikaInputStream pictureStream =
TikaInputStream.get(picture.data())) {
+ extractor.parseEmbedded(pictureStream, xhtml,
pictureMetadata, context, true);
+ }
+ }
+ }
+ }
+}
diff --git
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-audiovideo-module/src/main/java/org/apache/tika/parser/mp3/Mp3Parser.java
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-audiovideo-module/src/main/java/org/apache/tika/parser/mp3/Mp3Parser.java
index 3c34e0f775..b55a41ae6d 100644
---
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-audiovideo-module/src/main/java/org/apache/tika/parser/mp3/Mp3Parser.java
+++
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-audiovideo-module/src/main/java/org/apache/tika/parser/mp3/Mp3Parser.java
@@ -28,8 +28,6 @@ import org.xml.sax.SAXException;
import org.apache.tika.annotation.TikaComponent;
import org.apache.tika.exception.TikaException;
-import org.apache.tika.extractor.EmbeddedDocumentExtractor;
-import org.apache.tika.extractor.EmbeddedDocumentUtil;
import org.apache.tika.io.TailStream;
import org.apache.tika.io.TikaInputStream;
import org.apache.tika.metadata.Audio;
@@ -41,6 +39,7 @@ import org.apache.tika.metadata.XMPDM;
import org.apache.tika.mime.MediaType;
import org.apache.tika.parser.ParseContext;
import org.apache.tika.parser.Parser;
+import org.apache.tika.parser.audio.CoverArt;
import org.apache.tika.parser.audio.NumberAndTotal;
import org.apache.tika.parser.mp3.ID3Tags.ID3Comment;
import org.apache.tika.parser.mp3.ID3Tags.ID3Picture;
@@ -308,40 +307,21 @@ public class Mp3Parser implements Parser {
/**
* Sends the embedded pictures, such as cover art, from the ID3v2 tags
- * to the embedded document extractor. The pictures only become embedded
- * documents, no metadata is recorded on the audio document itself.
+ * to the embedded document extractor;
+ * {@link CoverArt#thumbnailIndex(java.util.List)} decides which of them
+ * is the file's thumbnail.
*/
private static void extractPictures(ID3Tags[] tags, XHTMLContentHandler
xhtml,
ParseContext context)
throws IOException, SAXException {
- EmbeddedDocumentExtractor extractor = null;
+ List<CoverArt.Picture> pictures = new ArrayList<>();
for (ID3Tags tag : tags) {
for (ID3Picture picture : tag.getPictures()) {
- if (extractor == null) {
- extractor =
EmbeddedDocumentUtil.getEmbeddedDocumentExtractor(context);
- }
- Metadata pictureMetadata = Metadata.newInstance(context);
- pictureMetadata.set(TikaCoreProperties.EMBEDDED_RESOURCE_TYPE,
-
TikaCoreProperties.EmbeddedResourceType.INLINE.toString());
- if (picture.getMimeType() != null) {
- pictureMetadata.set(HttpHeaders.CONTENT_TYPE,
picture.getMimeType());
- }
- if (picture.getDescription() != null &&
!picture.getDescription().isEmpty()) {
- pictureMetadata.set(TikaCoreProperties.TITLE,
picture.getDescription());
- }
- if (picture.getPictureType() >= 0 &&
- picture.getPictureType() <
ID3Tags.PICTURE_TYPES.length) {
- pictureMetadata.set(TikaCoreProperties.DESCRIPTION,
- ID3Tags.PICTURE_TYPES[picture.getPictureType()]);
- }
- if (extractor.shouldParseEmbedded(pictureMetadata, context)) {
- try (TikaInputStream pictureStream =
TikaInputStream.get(picture.getData())) {
- extractor.parseEmbedded(pictureStream, xhtml,
pictureMetadata, context,
- true);
- }
- }
+ pictures.add(new CoverArt.Picture(picture.getPictureType(),
+ picture.getMimeType(), picture.getDescription(),
picture.getData()));
}
}
+ CoverArt.extractPictures(pictures, xhtml, context);
}
/**
diff --git
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-audiovideo-module/src/main/java/org/apache/tika/parser/mp4/TikaMp4BoxHandler.java
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-audiovideo-module/src/main/java/org/apache/tika/parser/mp4/TikaMp4BoxHandler.java
index 4f3b55cf87..9230272719 100644
---
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-audiovideo-module/src/main/java/org/apache/tika/parser/mp4/TikaMp4BoxHandler.java
+++
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-audiovideo-module/src/main/java/org/apache/tika/parser/mp4/TikaMp4BoxHandler.java
@@ -23,6 +23,7 @@ import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
+import java.util.concurrent.atomic.AtomicInteger;
import com.drew.imaging.mp4.Mp4Handler;
import com.drew.lang.annotations.NotNull;
@@ -68,6 +69,8 @@ public class TikaMp4BoxHandler extends Mp4BoxHandler {
//duration of the current track's leading empty edit(s) ('elst' entries
//with media time -1), in movie timescale units; -1 if the track has none
private long emptyEditDuration = -1;
+ //cover images emitted so far, across all udta boxes of the file
+ private final AtomicInteger coverCount = new AtomicInteger();
public TikaMp4BoxHandler(Metadata metadata,
org.apache.tika.metadata.Metadata tikaMetadata,
XHTMLContentHandler xhtml, ParseContext
parseContext) {
@@ -148,7 +151,7 @@ public class TikaMp4BoxHandler extends Mp4BoxHandler {
return this;
}
try {
- new TikaUserDataBox(box, payload, tikaMetadata, xhtml,
parseContext)
+ new TikaUserDataBox(box, payload, tikaMetadata, xhtml,
parseContext, coverCount)
.addMetadata(directory);
} catch (SAXException e) {
throw new IOException(e);
diff --git
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-audiovideo-module/src/main/java/org/apache/tika/parser/mp4/boxes/TikaUserDataBox.java
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-audiovideo-module/src/main/java/org/apache/tika/parser/mp4/boxes/TikaUserDataBox.java
index e61b4e1de2..d63b4edeae 100644
---
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-audiovideo-module/src/main/java/org/apache/tika/parser/mp4/boxes/TikaUserDataBox.java
+++
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-audiovideo-module/src/main/java/org/apache/tika/parser/mp4/boxes/TikaUserDataBox.java
@@ -18,6 +18,7 @@ package org.apache.tika.parser.mp4.boxes;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
+import java.util.concurrent.atomic.AtomicInteger;
import com.drew.lang.SequentialByteArrayReader;
import com.drew.lang.SequentialReader;
@@ -52,15 +53,30 @@ public class TikaUserDataBox {
private String coordinateString;
private boolean isQuickTime = false;
+ //covr carries no picture type, so the first cover of the file is the
+ //thumbnail; the count is shared across the file's udta boxes
+ private final AtomicInteger coverCount;
private final Metadata metadata;
private final XHTMLContentHandler xhtml;
private final ParseContext parseContext;
public TikaUserDataBox(@NotNull String box, byte[] payload, Metadata
metadata,
XHTMLContentHandler xhtml, ParseContext
parseContext)
throws IOException, SAXException {
+ this(box, payload, metadata, xhtml, parseContext, new AtomicInteger());
+ }
+
+ /**
+ * @param coverCount the number of cover images already emitted for the
+ * file, shared across its udta boxes
+ */
+ public TikaUserDataBox(@NotNull String box, byte[] payload, Metadata
metadata,
+ XHTMLContentHandler xhtml, ParseContext
parseContext,
+ AtomicInteger coverCount)
+ throws IOException, SAXException {
this.metadata = metadata;
this.xhtml = xhtml;
this.parseContext = parseContext;
+ this.coverCount = coverCount;
int length = payload.length;
SequentialReader reader = new SequentialByteArrayReader(payload);
while (reader.getPosition() < (long) length) {
@@ -228,16 +244,19 @@ public class TikaUserDataBox {
/**
- * Sends one embedded cover image to the embedded document extractor.
- * The image only becomes an embedded document, no metadata is recorded
- * on the audio document itself.
+ * Sends one embedded cover image to the embedded document extractor:
+ * the first as the file's thumbnail, any further one as an inline
+ * picture. The image only becomes an embedded document, no metadata is
+ * recorded on the audio document itself.
*/
private void handleCoverArt(SequentialReader reader, long valueType, int
length)
throws IOException {
byte[] picture = reader.getBytes(length);
Metadata pictureMetadata = Metadata.newInstance(parseContext);
pictureMetadata.set(TikaCoreProperties.EMBEDDED_RESOURCE_TYPE,
- TikaCoreProperties.EmbeddedResourceType.INLINE.toString());
+ (coverCount.getAndIncrement() == 0
+ ? TikaCoreProperties.EmbeddedResourceType.THUMBNAIL
+ :
TikaCoreProperties.EmbeddedResourceType.INLINE).name());
//the data atom's well-known value type declares the image format;
//for any other type leave the content type for auto-detection
if (valueType == 13) {
diff --git
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-audiovideo-module/src/main/java/org/apache/tika/parser/ogg/FlacParser.java
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-audiovideo-module/src/main/java/org/apache/tika/parser/ogg/FlacParser.java
index 0fca37e69a..6af228efd7 100644
---
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-audiovideo-module/src/main/java/org/apache/tika/parser/ogg/FlacParser.java
+++
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-audiovideo-module/src/main/java/org/apache/tika/parser/ogg/FlacParser.java
@@ -22,6 +22,7 @@ import java.io.IOException;
import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.Path;
+import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashSet;
import java.util.List;
@@ -36,8 +37,6 @@ import org.xml.sax.SAXException;
import org.apache.tika.annotation.TikaComponent;
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;
@@ -101,15 +100,18 @@ public class FlacParser extends AbstractParser {
metadata.set(HttpHeaders.CONTENT_TYPE, NATIVE_FLAC.toString());
}
- // Extract any Vorbis-style comments
- OggAudioParser.extractComments(metadata, xhtml, flac.getTags(),
context);
+ // Extract any Vorbis-style comments; their pictures are emitted
+ // below, together with any native PICTURE blocks, so one file
+ // yields exactly one thumbnail
+ List<OggAudioParser.PictureBlock> pictures =
+ OggAudioParser.extractComments(metadata, xhtml,
flac.getTags());
- // Extract any embedded pictures, such as cover art, from native
- // FLAC PICTURE metadata blocks (Ogg-contained FLAC carries its
- // pictures in metadata_block_picture comments instead)
+ // Native FLAC carries its pictures in PICTURE metadata blocks
+ // (Ogg-contained FLAC uses metadata_block_picture comments instead)
if (!(flac instanceof FlacOggFile)) {
- extractNativePictures(path, xhtml, context);
+ pictures.addAll(readNativePictures(path));
}
+ OggAudioParser.extractPictures(pictures, xhtml, context);
// Extract duration if available from header
FlacInfo info = flac.getInfo();
@@ -129,8 +131,8 @@ public class FlacParser extends AbstractParser {
}
/**
- * Walks the metadata blocks of a native FLAC file and sends any
- * PICTURE blocks to the embedded document extractor. Their payload is
+ * Walks the metadata blocks of a native FLAC file and returns any
+ * PICTURE blocks. Their payload is
* identical to the metadata_block_picture comments handled by
* {@link OggAudioParser}. vorbis-java parses these blocks but keeps
* them without a public accessor, so they are read through a second
@@ -141,14 +143,13 @@ public class FlacParser extends AbstractParser {
* FlacFile.getOtherMetadata(), present on their master but unreleased
* as of 0.8, see https://github.com/Gagravarr/VorbisJava/issues/46
*/
- private static void extractNativePictures(Path path, XHTMLContentHandler
xhtml,
- ParseContext context) throws IOException, SAXException {
- EmbeddedDocumentExtractor extractor = null;
+ static List<OggAudioParser.PictureBlock> readNativePictures(Path path)
throws IOException {
+ List<OggAudioParser.PictureBlock> pictures = new ArrayList<>();
try (InputStream stream = new
BufferedInputStream(Files.newInputStream(path))) {
byte[] magic = stream.readNBytes(4);
if (magic.length != 4 || magic[0] != 'f' || magic[1] != 'L'
|| magic[2] != 'a' || magic[3] != 'C') {
- return;
+ return pictures;
}
boolean lastBlock = false;
while (!lastBlock) {
@@ -156,7 +157,7 @@ public class FlacParser extends AbstractParser {
// 24 bit BE block length
byte[] header = stream.readNBytes(4);
if (header.length != 4) {
- return;
+ break;
}
lastBlock = (header[0] & 0x80) != 0;
int blockType = header[0] & 0x7F;
@@ -165,21 +166,22 @@ public class FlacParser extends AbstractParser {
if (blockType == PICTURE_BLOCK_TYPE) {
byte[] block = stream.readNBytes(blockLength);
if (block.length != blockLength) {
- return;
+ break;
}
- if (extractor == null) {
- extractor =
EmbeddedDocumentUtil.getEmbeddedDocumentExtractor(context);
+ OggAudioParser.PictureBlock picture =
OggAudioParser.PictureBlock.parse(block);
+ if (picture != null) {
+ pictures.add(picture);
}
- OggAudioParser.extractPictureBlock(block, xhtml, context,
extractor);
} else {
try {
stream.skipNBytes(blockLength);
} catch (EOFException e) {
//truncated block, stop the walk
- return;
+ break;
}
}
}
}
+ return pictures;
}
}
diff --git
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-audiovideo-module/src/main/java/org/apache/tika/parser/ogg/OggAudioParser.java
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-audiovideo-module/src/main/java/org/apache/tika/parser/ogg/OggAudioParser.java
index e75e7873a4..381469365c 100644
---
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-audiovideo-module/src/main/java/org/apache/tika/parser/ogg/OggAudioParser.java
+++
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-audiovideo-module/src/main/java/org/apache/tika/parser/ogg/OggAudioParser.java
@@ -23,6 +23,7 @@ import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets;
import java.text.DecimalFormat;
import java.text.NumberFormat;
+import java.util.ArrayList;
import java.util.Arrays;
import java.util.Base64;
import java.util.List;
@@ -37,11 +38,7 @@ import org.gagravarr.vorbis.VorbisStyleComments;
import org.xml.sax.SAXException;
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.Audio;
-import org.apache.tika.metadata.HttpHeaders;
import org.apache.tika.metadata.KeyPrefix;
import org.apache.tika.metadata.Metadata;
import org.apache.tika.metadata.Property;
@@ -50,8 +47,8 @@ import org.apache.tika.metadata.XMP;
import org.apache.tika.metadata.XMPDM;
import org.apache.tika.parser.AbstractParser;
import org.apache.tika.parser.ParseContext;
+import org.apache.tika.parser.audio.CoverArt;
import org.apache.tika.parser.audio.NumberAndTotal;
-import org.apache.tika.parser.mp3.ID3Tags;
import org.apache.tika.sax.XHTMLContentHandler;
/**
@@ -117,8 +114,14 @@ public abstract class OggAudioParser extends
AbstractParser {
}
}
- protected static void extractComments(Metadata metadata,
XHTMLContentHandler xhtml,
- VorbisStyleComments comments, ParseContext context)
+ /**
+ * @return the pictures carried in the comments; the caller emits them
+ * through {@link #extractPictures(List, XHTMLContentHandler,
ParseContext)},
+ * {@link FlacParser} first merges them with the native PICTURE blocks so
+ * one file yields one thumbnail
+ */
+ protected static List<PictureBlock> extractComments(Metadata metadata,
+ XHTMLContentHandler xhtml, VorbisStyleComments comments)
throws IOException, TikaException, SAXException {
// Get the specific known comments
metadata.set(TikaCoreProperties.TITLE, comments.getTitle());
@@ -207,22 +210,17 @@ public abstract class OggAudioParser extends
AbstractParser {
}
xhtml.element("p", comments.getGenre());
- // Any embedded pictures, such as cover art, become
- // embedded documents of the audio file
- extractPictures(xhtml, comments, context);
+ // The pictures are the caller's to emit
+ return parsePictures(comments);
}
/**
- * Sends the embedded pictures, such as cover art, from the comments to
- * the embedded document extractor. The pictures are carried as base64
- * encoded FLAC picture blocks; malformed blocks are skipped silently.
- * The pictures only become embedded documents, no metadata is recorded
- * on the audio document itself.
+ * Parses the embedded pictures, such as cover art, out of the comments.
+ * The pictures are carried as base64 encoded FLAC picture blocks;
+ * malformed blocks are skipped silently.
*/
- private static void extractPictures(XHTMLContentHandler xhtml,
- VorbisStyleComments comments, ParseContext context)
- throws IOException, SAXException {
- EmbeddedDocumentExtractor extractor = null;
+ private static List<PictureBlock> parsePictures(VorbisStyleComments
comments) {
+ List<PictureBlock> pictures = new ArrayList<>();
for (String block : comments.getComments(METADATA_BLOCK_PICTURE)) {
byte[] decoded;
try {
@@ -231,80 +229,80 @@ public abstract class OggAudioParser extends
AbstractParser {
//not valid base64, skip
continue;
}
- if (extractor == null) {
- extractor =
EmbeddedDocumentUtil.getEmbeddedDocumentExtractor(context);
+ PictureBlock picture = PictureBlock.parse(decoded);
+ if (picture != null) {
+ pictures.add(picture);
}
- extractPictureBlock(decoded, xhtml, context, extractor);
}
+ return pictures;
}
/**
- * Parses one FLAC picture block and sends the picture it holds to the
- * embedded document extractor. Native FLAC PICTURE metadata blocks use
- * the very same structure, so {@link FlacParser} shares this method.
- * Malformed or truncated blocks are skipped silently.
+ * Sends parsed picture blocks to the embedded document extractor;
+ * {@link CoverArt#thumbnailIndex(List)} decides which of them is the
+ * file's thumbnail. Native FLAC PICTURE metadata blocks use the very
+ * same structure, so {@link FlacParser} shares this method.
*/
- static void extractPictureBlock(byte[] block, XHTMLContentHandler xhtml,
- ParseContext context, EmbeddedDocumentExtractor extractor)
- throws IOException, SAXException {
- // The picture block holds a 32 bit BE picture type, the mime
- // type, the description, the image geometry and the picture
- // data, with mime type, description and data length prefixed
- int pictureType;
- String mimeType;
- String description;
- byte[] picture;
- try {
- ByteBuffer buffer = ByteBuffer.wrap(block);
- pictureType = buffer.getInt();
- mimeType = getPrefixedString(buffer, StandardCharsets.ISO_8859_1);
- if (mimeType == null || "-->".equals(mimeType)) {
- // Malformed, or a link to a picture rather than an
- // embedded one
- return;
- }
- description = getPrefixedString(buffer, StandardCharsets.UTF_8);
- if (description == null) {
- return;
- }
- // Width, height, color depth and number of colors
- buffer.position(buffer.position() + 16);
- int dataLength = buffer.getInt();
- if (dataLength <= 0 || dataLength > buffer.remaining()) {
- return;
- }
- picture = new byte[dataLength];
- buffer.get(picture);
- } catch (BufferUnderflowException | IllegalArgumentException e) {
- //truncated picture block, skip
- return;
+ static void extractPictures(List<PictureBlock> pictures,
XHTMLContentHandler xhtml,
+ ParseContext context) throws IOException, SAXException {
+ List<CoverArt.Picture> mapped = new ArrayList<>();
+ for (PictureBlock picture : pictures) {
+ mapped.add(new CoverArt.Picture(picture.pictureType,
picture.mimeType,
+ picture.description, picture.data));
}
+ CoverArt.extractPictures(mapped, xhtml, context);
+ }
- Metadata pictureMetadata = Metadata.newInstance(context);
- pictureMetadata.set(TikaCoreProperties.EMBEDDED_RESOURCE_TYPE,
- TikaCoreProperties.EmbeddedResourceType.INLINE.toString());
- if (!mimeType.isEmpty()) {
- pictureMetadata.set(HttpHeaders.CONTENT_TYPE, mimeType);
- }
- if (!description.isEmpty()) {
- pictureMetadata.set(TikaCoreProperties.TITLE, description);
- }
- //the FLAC picture block reuses the ID3v2 APIC picture types
- if (pictureType >= 0 && pictureType < ID3Tags.PICTURE_TYPES.length) {
- pictureMetadata.set(TikaCoreProperties.DESCRIPTION,
- ID3Tags.PICTURE_TYPES[pictureType]);
+ /**
+ * A FLAC picture block: a 32 bit BE picture type, the mime type, the
+ * description, the image geometry and the picture data, with mime type,
+ * description and data length prefixed.
+ */
+ static final class PictureBlock {
+ final int pictureType;
+ final String mimeType;
+ final String description;
+ final byte[] data;
+
+ private PictureBlock(int pictureType, String mimeType, String
description, byte[] data) {
+ this.pictureType = pictureType;
+ this.mimeType = mimeType;
+ this.description = description;
+ this.data = data;
}
- if (extractor.shouldParseEmbedded(pictureMetadata, context)) {
- try (TikaInputStream pictureStream = TikaInputStream.get(picture))
{
- extractor.parseEmbedded(pictureStream, xhtml, pictureMetadata,
context, true);
+
+ /**
+ * Parses a picture block, or returns null for a malformed or
+ * truncated one, or one that links to a picture instead of
+ * embedding it.
+ */
+ static PictureBlock parse(byte[] block) {
+ try {
+ ByteBuffer buffer = ByteBuffer.wrap(block);
+ int pictureType = buffer.getInt();
+ String mimeType = getPrefixedString(buffer,
StandardCharsets.ISO_8859_1);
+ if (mimeType == null || "-->".equals(mimeType)) {
+ return null;
+ }
+ String description = getPrefixedString(buffer,
StandardCharsets.UTF_8);
+ if (description == null) {
+ return null;
+ }
+ // Width, height, color depth and number of colors
+ buffer.position(buffer.position() + 16);
+ int dataLength = buffer.getInt();
+ if (dataLength <= 0 || dataLength > buffer.remaining()) {
+ return null;
+ }
+ byte[] data = new byte[dataLength];
+ buffer.get(data);
+ return new PictureBlock(pictureType, mimeType, description,
data);
+ } catch (BufferUnderflowException | IllegalArgumentException e) {
+ return null;
}
}
}
- /**
- * Reads a 32 bit length prefixed string from the buffer, or null if the
- * declared length is invalid for the remaining data.
- */
private static String getPrefixedString(ByteBuffer buffer, Charset
charset) {
int length = buffer.getInt();
if (length < 0 || length > buffer.remaining()) {
diff --git
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-audiovideo-module/src/main/java/org/apache/tika/parser/ogg/OpusParser.java
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-audiovideo-module/src/main/java/org/apache/tika/parser/ogg/OpusParser.java
index 3329aa53ac..d9c4a4360a 100644
---
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-audiovideo-module/src/main/java/org/apache/tika/parser/ogg/OpusParser.java
+++
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-audiovideo-module/src/main/java/org/apache/tika/parser/ogg/OpusParser.java
@@ -77,7 +77,8 @@ public class OpusParser extends OggAudioParser {
extractInfo(metadata, opus.getInfo());
// Extract any Vorbis comments
- extractComments(metadata, xhtml, opus.getTags(), context);
+ extractPictures(extractComments(metadata, xhtml, opus.getTags()),
+ xhtml, context);
// Extract the audio length
extractDuration(metadata, xhtml, opus, opus);
diff --git
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-audiovideo-module/src/main/java/org/apache/tika/parser/ogg/SpeexParser.java
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-audiovideo-module/src/main/java/org/apache/tika/parser/ogg/SpeexParser.java
index 4e9b178780..104bf8f3bf 100644
---
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-audiovideo-module/src/main/java/org/apache/tika/parser/ogg/SpeexParser.java
+++
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-audiovideo-module/src/main/java/org/apache/tika/parser/ogg/SpeexParser.java
@@ -77,7 +77,8 @@ public class SpeexParser extends OggAudioParser {
extractInfo(metadata, speex.getInfo());
// Extract any Vorbis comments
- extractComments(metadata, xhtml, speex.getTags(), context);
+ extractPictures(extractComments(metadata, xhtml, speex.getTags()),
+ xhtml, context);
// Extract the audio length
extractDuration(metadata, xhtml, speex, speex);
diff --git
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-audiovideo-module/src/main/java/org/apache/tika/parser/ogg/TheoraParser.java
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-audiovideo-module/src/main/java/org/apache/tika/parser/ogg/TheoraParser.java
index a201c47604..09639e09cf 100644
---
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-audiovideo-module/src/main/java/org/apache/tika/parser/ogg/TheoraParser.java
+++
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-audiovideo-module/src/main/java/org/apache/tika/parser/ogg/TheoraParser.java
@@ -78,7 +78,9 @@ public class TheoraParser extends AbstractParser {
extractInfo(metadata, theora.getInfo());
// Extract the common Theora comments
- OggAudioParser.extractComments(metadata, xhtml, theora.getComments(),
context);
+ OggAudioParser.extractPictures(
+ OggAudioParser.extractComments(metadata, xhtml,
theora.getComments()),
+ xhtml, context);
// Extract any soundtracks
for (OggAudioHeaders audio : theora.getSoundtracks()) {
diff --git
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-audiovideo-module/src/main/java/org/apache/tika/parser/ogg/VorbisParser.java
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-audiovideo-module/src/main/java/org/apache/tika/parser/ogg/VorbisParser.java
index d38bba037e..d19d29b028 100644
---
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-audiovideo-module/src/main/java/org/apache/tika/parser/ogg/VorbisParser.java
+++
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-audiovideo-module/src/main/java/org/apache/tika/parser/ogg/VorbisParser.java
@@ -76,7 +76,8 @@ public class VorbisParser extends OggAudioParser {
extractInfo(metadata, vorbis.getInfo());
// Extract any Vorbis comments
- extractComments(metadata, xhtml, vorbis.getComment(), context);
+ extractPictures(extractComments(metadata, xhtml, vorbis.getComment()),
+ xhtml, context);
// Extract the audio length
extractDuration(metadata, xhtml, vorbis, vorbis);
diff --git
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-audiovideo-module/src/test/java/org/apache/tika/parser/audio/CoverArtTest.java
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-audiovideo-module/src/test/java/org/apache/tika/parser/audio/CoverArtTest.java
new file mode 100644
index 0000000000..3d73d6f251
--- /dev/null
+++
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-audiovideo-module/src/test/java/org/apache/tika/parser/audio/CoverArtTest.java
@@ -0,0 +1,59 @@
+/*
+ * 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.audio;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+import java.util.Arrays;
+import java.util.Collections;
+
+import org.junit.jupiter.api.Test;
+
+public class CoverArtTest {
+
+ @Test
+ public void testFrontCoverWins() {
+ //back cover, front cover: the front cover, wherever it is
+ assertEquals(1, CoverArt.thumbnailIndex(Arrays.asList(4, 3)));
+ assertEquals(0, CoverArt.thumbnailIndex(Arrays.asList(3, 0)));
+ }
+
+ @Test
+ public void testOtherBeatsClassifiedNonCovers() {
+ //back cover, leaflet, "Other": the unclassified one is the main art
+ assertEquals(2, CoverArt.thumbnailIndex(Arrays.asList(4, 5, 0)));
+ //unknown type counts like "Other"
+ assertEquals(1, CoverArt.thumbnailIndex(Arrays.asList(4, -1)));
+ }
+
+ @Test
+ public void testTypesBeyondTheId3TableCountAsUnknown() {
+ assertEquals(-1, new CoverArt.Picture(200, null, null, new
byte[0]).normalizedType());
+ assertEquals(CoverArt.FRONT_COVER,
+ new CoverArt.Picture(CoverArt.FRONT_COVER, null, null, new
byte[0])
+ .normalizedType());
+ //a back cover first, then an out-of-table type: the unknown one wins
+ assertEquals(1, CoverArt.thumbnailIndex(Arrays.asList(4,
+ new CoverArt.Picture(200, null, null, new
byte[0]).normalizedType())));
+ }
+
+ @Test
+ public void testFirstPictureAsLastResort() {
+ assertEquals(0, CoverArt.thumbnailIndex(Arrays.asList(4, 5)));
+ assertEquals(-1, CoverArt.thumbnailIndex(Collections.emptyList()));
+ }
+}
diff --git
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-audiovideo-module/src/test/java/org/apache/tika/parser/mp3/Mp3ParserTest.java
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-audiovideo-module/src/test/java/org/apache/tika/parser/mp3/Mp3ParserTest.java
index b48dee0f56..59808166c1 100644
---
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-audiovideo-module/src/test/java/org/apache/tika/parser/mp3/Mp3ParserTest.java
+++
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-audiovideo-module/src/test/java/org/apache/tika/parser/mp3/Mp3ParserTest.java
@@ -145,7 +145,8 @@ public class Mp3ParserTest extends TikaTest {
Metadata pictureMetadata = metadataList.get(1);
assertEquals("image/png",
pictureMetadata.get(HttpHeaders.CONTENT_TYPE));
- assertEquals(TikaCoreProperties.EmbeddedResourceType.INLINE.toString(),
+ //the front cover is the file's thumbnail
+
assertEquals(TikaCoreProperties.EmbeddedResourceType.THUMBNAIL.toString(),
pictureMetadata.get(TikaCoreProperties.EMBEDDED_RESOURCE_TYPE));
assertEquals("Test Cover",
pictureMetadata.get(TikaCoreProperties.TITLE));
assertEquals("Cover (front)",
pictureMetadata.get(TikaCoreProperties.DESCRIPTION));
@@ -182,7 +183,7 @@ public class Mp3ParserTest extends TikaTest {
//the 30x30 back cover second
Metadata front = metadataList.get(1);
assertEquals("image/png", front.get(HttpHeaders.CONTENT_TYPE));
- assertEquals(TikaCoreProperties.EmbeddedResourceType.INLINE.toString(),
+
assertEquals(TikaCoreProperties.EmbeddedResourceType.THUMBNAIL.toString(),
front.get(TikaCoreProperties.EMBEDDED_RESOURCE_TYPE));
assertEquals("Front Cover", front.get(TikaCoreProperties.TITLE));
assertEquals("Cover (front)",
front.get(TikaCoreProperties.DESCRIPTION));
@@ -521,6 +522,73 @@ public class Mp3ParserTest extends TikaTest {
}
// wraps the audio of testMP3noid3.mp3 in an ID3v2.3 tag holding the
single given frame
+ /**
+ * The thumbnail follows the picture types, not the file order: a back
+ * cover that comes first stays INLINE, the front cover behind it is
+ * the THUMBNAIL.
+ */
+ @Test
+ public void testBackCoverBeforeFrontCover() throws Exception {
+ byte[] mp3 = mp3WithFrames(
+ id3Frame("APIC", apicBody(4, "the back")),
+ id3Frame("APIC", apicBody(3, "the front")));
+ try (TikaInputStream tis = TikaInputStream.get(mp3)) {
+ List<Metadata> metadataList =
+ getRecursiveMetadata(tis, new Metadata(), new
ParseContext(), false);
+ assertEquals(3, metadataList.size());
+ assertEquals("Cover (back)",
metadataList.get(1).get(TikaCoreProperties.DESCRIPTION));
+ assertEquals(TikaCoreProperties.EmbeddedResourceType.INLINE.name(),
+
metadataList.get(1).get(TikaCoreProperties.EMBEDDED_RESOURCE_TYPE));
+ assertEquals("Cover (front)",
metadataList.get(2).get(TikaCoreProperties.DESCRIPTION));
+
assertEquals(TikaCoreProperties.EmbeddedResourceType.THUMBNAIL.name(),
+
metadataList.get(2).get(TikaCoreProperties.EMBEDDED_RESOURCE_TYPE));
+ }
+ }
+
+ private static byte[] apicBody(int pictureType, String description) throws
Exception {
+ ByteArrayOutputStream body = new ByteArrayOutputStream();
+ body.write(0); //ISO-8859-1
+ body.write("image/png".getBytes(StandardCharsets.ISO_8859_1));
+ body.write(0);
+ body.write(pictureType);
+ body.write(description.getBytes(StandardCharsets.ISO_8859_1));
+ body.write(0);
+ body.write(new byte[]{(byte) 0x89, 'P', 'N', 'G', '\r', '\n', 0x1a,
'\n'});
+ return body.toByteArray();
+ }
+
+ private static byte[] id3Frame(String frameId, byte[] body) throws
Exception {
+ ByteArrayOutputStream frame = new ByteArrayOutputStream();
+ frame.write(frameId.getBytes(StandardCharsets.US_ASCII));
+ //ID3v2.3 frame sizes are plain 32 bit big endian
+ frame.write(new byte[]{(byte) (body.length >>> 24), (byte)
(body.length >>> 16),
+ (byte) (body.length >>> 8), (byte) body.length});
+ frame.write(new byte[]{0, 0});
+ frame.write(body);
+ return frame.toByteArray();
+ }
+
+ private byte[] mp3WithFrames(byte[]... frames) throws Exception {
+ byte[] audio;
+ try (TikaInputStream tis =
getResourceAsStream("/test-documents/testMP3noid3.mp3")) {
+ audio = tis.readAllBytes();
+ }
+ ByteArrayOutputStream body = new ByteArrayOutputStream();
+ for (byte[] frame : frames) {
+ body.write(frame);
+ }
+ ByteArrayOutputStream mp3 = new ByteArrayOutputStream();
+ mp3.write("ID3".getBytes(StandardCharsets.US_ASCII));
+ mp3.write(new byte[]{3, 0, 0});
+ //the tag size is synchsafe (7 bits per byte)
+ int size = body.size();
+ mp3.write(new byte[]{(byte) ((size >>> 21) & 0x7f), (byte) ((size >>>
14) & 0x7f),
+ (byte) ((size >>> 7) & 0x7f), (byte) (size & 0x7f)});
+ mp3.write(body.toByteArray());
+ mp3.write(audio);
+ return mp3.toByteArray();
+ }
+
private byte[] mp3WithFrame(String frameId, byte[] body) throws Exception {
byte[] audio;
try (TikaInputStream tis =
getResourceAsStream("/test-documents/testMP3noid3.mp3")) {
diff --git
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-audiovideo-module/src/test/java/org/apache/tika/parser/mp4/MP4ParserTest.java
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-audiovideo-module/src/test/java/org/apache/tika/parser/mp4/MP4ParserTest.java
index ef8d1f9c33..c1b663ff2d 100644
---
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-audiovideo-module/src/test/java/org/apache/tika/parser/mp4/MP4ParserTest.java
+++
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-audiovideo-module/src/test/java/org/apache/tika/parser/mp4/MP4ParserTest.java
@@ -24,6 +24,7 @@ import static org.junit.jupiter.api.Assertions.assertTrue;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
+import java.io.InputStream;
import java.nio.ByteBuffer;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
@@ -165,7 +166,8 @@ public class MP4ParserTest extends TikaTest {
Metadata pictureMetadata = metadataList.get(1);
assertEquals("image/png",
pictureMetadata.get(HttpHeaders.CONTENT_TYPE));
- assertEquals(TikaCoreProperties.EmbeddedResourceType.INLINE.toString(),
+ //the cover is the file's thumbnail
+
assertEquals(TikaCoreProperties.EmbeddedResourceType.THUMBNAIL.toString(),
pictureMetadata.get(TikaCoreProperties.EMBEDDED_RESOURCE_TYPE));
}
@@ -179,9 +181,10 @@ public class MP4ParserTest extends TikaTest {
assertEquals(3, metadataList.size());
//a png data atom (well-known type 14) followed by a jpeg one (13)
+ //covr carries no picture type, so the first image is the thumbnail
Metadata front = metadataList.get(1);
assertEquals("image/png", front.get(HttpHeaders.CONTENT_TYPE));
- assertEquals(TikaCoreProperties.EmbeddedResourceType.INLINE.toString(),
+
assertEquals(TikaCoreProperties.EmbeddedResourceType.THUMBNAIL.toString(),
front.get(TikaCoreProperties.EMBEDDED_RESOURCE_TYPE));
Metadata back = metadataList.get(2);
assertEquals("image/jpeg", back.get(HttpHeaders.CONTENT_TYPE));
@@ -189,6 +192,45 @@ public class MP4ParserTest extends TikaTest {
back.get(TikaCoreProperties.EMBEDDED_RESOURCE_TYPE));
}
+ /**
+ * A cover in a second udta box is still an inline picture: the first
+ * cover of the file is its thumbnail, not the first of every box
+ */
+ @Test
+ public void testCoversAcrossUserDataBoxes() throws Exception {
+ byte[] file;
+ try (InputStream is =
getResourceAsStream("/test-documents/testMP4_coverArt.m4a")) {
+ file = is.readAllBytes();
+ }
+ //append a copy of the file's udta box (with its covr) at the top level
+ int udtaName = indexOf(file,
"udta".getBytes(StandardCharsets.ISO_8859_1));
+ assertTrue(udtaName >= 4, "fixture must contain a udta box");
+ int udta = udtaName - 4;
+ int size = ByteBuffer.wrap(file, udta, 4).getInt();
+ ByteArrayOutputStream bos = new ByteArrayOutputStream();
+ bos.write(file);
+ bos.write(file, udta, size);
+
+ List<Metadata> metadataList;
+ try (TikaInputStream tis = TikaInputStream.get(bos.toByteArray())) {
+ metadataList = getRecursiveMetadata(tis, new Metadata(), new
ParseContext(), false);
+ }
+ assertEquals(3, metadataList.size());
+
assertEquals(TikaCoreProperties.EmbeddedResourceType.THUMBNAIL.toString(),
+
metadataList.get(1).get(TikaCoreProperties.EMBEDDED_RESOURCE_TYPE));
+ assertEquals(TikaCoreProperties.EmbeddedResourceType.INLINE.toString(),
+
metadataList.get(2).get(TikaCoreProperties.EMBEDDED_RESOURCE_TYPE));
+ }
+
+ private static int indexOf(byte[] haystack, byte[] needle) {
+ for (int i = 0; i <= haystack.length - needle.length; i++) {
+ if (Arrays.equals(Arrays.copyOfRange(haystack, i, i +
needle.length), needle)) {
+ return i;
+ }
+ }
+ return -1;
+ }
+
// TODO Test an old QuickTime Video File
@Test
public void testVideoFrameRate() throws Exception {
diff --git
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-audiovideo-module/src/test/java/org/apache/tika/parser/ogg/FlacParserTest.java
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-audiovideo-module/src/test/java/org/apache/tika/parser/ogg/FlacParserTest.java
index e9dc263ec6..4fde9e14ee 100644
---
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-audiovideo-module/src/test/java/org/apache/tika/parser/ogg/FlacParserTest.java
+++
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-audiovideo-module/src/test/java/org/apache/tika/parser/ogg/FlacParserTest.java
@@ -18,9 +18,15 @@ package org.apache.tika.parser.ogg;
import static org.junit.jupiter.api.Assertions.assertEquals;
+import java.io.ByteArrayOutputStream;
+import java.io.DataOutputStream;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
import java.util.List;
import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
import org.apache.tika.TikaTest;
import org.apache.tika.metadata.HttpHeaders;
@@ -45,7 +51,7 @@ public class FlacParserTest extends TikaTest {
Metadata pictureMetadata = metadataList.get(1);
assertEquals("image/png",
pictureMetadata.get(HttpHeaders.CONTENT_TYPE));
- assertEquals(TikaCoreProperties.EmbeddedResourceType.INLINE.toString(),
+
assertEquals(TikaCoreProperties.EmbeddedResourceType.THUMBNAIL.toString(),
pictureMetadata.get(TikaCoreProperties.EMBEDDED_RESOURCE_TYPE));
assertEquals("Test Cover",
pictureMetadata.get(TikaCoreProperties.TITLE));
assertEquals("Cover (front)",
pictureMetadata.get(TikaCoreProperties.DESCRIPTION));
@@ -65,10 +71,87 @@ public class FlacParserTest extends TikaTest {
assertEquals("image/png", front.get(HttpHeaders.CONTENT_TYPE));
assertEquals("Front Cover", front.get(TikaCoreProperties.TITLE));
assertEquals("Cover (front)",
front.get(TikaCoreProperties.DESCRIPTION));
+
assertEquals(TikaCoreProperties.EmbeddedResourceType.THUMBNAIL.toString(),
+ front.get(TikaCoreProperties.EMBEDDED_RESOURCE_TYPE));
Metadata back = metadataList.get(2);
assertEquals("image/png", back.get(HttpHeaders.CONTENT_TYPE));
assertEquals("Back Cover", back.get(TikaCoreProperties.TITLE));
assertEquals("Cover (back)", back.get(TikaCoreProperties.DESCRIPTION));
+ assertEquals(TikaCoreProperties.EmbeddedResourceType.INLINE.toString(),
+ back.get(TikaCoreProperties.EMBEDDED_RESOURCE_TYPE));
+ }
+
+ /**
+ * A file carrying a picture in a metadata_block_picture comment and
+ * another in a native PICTURE block still has exactly one thumbnail:
+ * both sources are merged before the pick (both are front covers here,
+ * so the first one, from the comment, wins).
+ */
+ @Test
+ public void testCommentAndNativePictureYieldOneThumbnail() throws
Exception {
+ List<Metadata> metadataList =
+ getRecursiveMetadata("testFLAC_commentAndNativePicture.flac");
+
+ assertEquals(3, metadataList.size());
+ int thumbnails = 0;
+ for (Metadata m : metadataList) {
+ if (TikaCoreProperties.EmbeddedResourceType.THUMBNAIL.name()
+ .equals(m.get(TikaCoreProperties.EMBEDDED_RESOURCE_TYPE)))
{
+ thumbnails++;
+ }
+ }
+ assertEquals(1, thumbnails);
+ assertEquals("Comment cover",
metadataList.get(1).get(TikaCoreProperties.TITLE));
+ assertEquals(TikaCoreProperties.EmbeddedResourceType.THUMBNAIL.name(),
+
metadataList.get(1).get(TikaCoreProperties.EMBEDDED_RESOURCE_TYPE));
+ assertEquals(TikaCoreProperties.EmbeddedResourceType.INLINE.name(),
+
metadataList.get(2).get(TikaCoreProperties.EMBEDDED_RESOURCE_TYPE));
+ }
+
+ /**
+ * A PICTURE block that declares more data than the file has left ends
+ * the walk, but the pictures before it survive: the walk breaks instead
+ * of returning or throwing (regression guard for the return-vs-break in
+ * readNativePictures).
+ */
+ @Test
+ public void testTruncatedPictureBlockKeepsEarlierPictures(@TempDir Path
tmp) throws Exception {
+ ByteArrayOutputStream out = new ByteArrayOutputStream();
+ out.write("fLaC".getBytes(StandardCharsets.US_ASCII));
+ byte[] picture = pictureBlock(3, "image/png", "front");
+ out.write(blockHeader(false, 6, picture.length));
+ out.write(picture);
+ //a second PICTURE block declaring far more data than follows
+ out.write(blockHeader(true, 6, 0x00FFFF));
+ out.write(new byte[]{1, 2, 3});
+ Path flac = tmp.resolve("truncated.flac");
+ Files.write(flac, out.toByteArray());
+
+ List<OggAudioParser.PictureBlock> pictures =
FlacParser.readNativePictures(flac);
+ assertEquals(1, pictures.size());
+ }
+
+ private static byte[] blockHeader(boolean last, int type, int length) {
+ return new byte[]{(byte) ((last ? 0x80 : 0) | type),
+ (byte) (length >>> 16), (byte) (length >>> 8), (byte) length};
+ }
+
+ private static byte[] pictureBlock(int pictureType, String mimeType,
String description)
+ throws Exception {
+ ByteArrayOutputStream out = new ByteArrayOutputStream();
+ DataOutputStream data = new DataOutputStream(out);
+ data.writeInt(pictureType);
+ byte[] mime = mimeType.getBytes(StandardCharsets.ISO_8859_1);
+ data.writeInt(mime.length);
+ data.write(mime);
+ byte[] desc = description.getBytes(StandardCharsets.UTF_8);
+ data.writeInt(desc.length);
+ data.write(desc);
+ data.write(new byte[16]); //geometry
+ byte[] image = {(byte) 0x89, 'P', 'N', 'G'};
+ data.writeInt(image.length);
+ data.write(image);
+ return out.toByteArray();
}
}
diff --git
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-audiovideo-module/src/test/java/org/apache/tika/parser/ogg/OggAudioParserTest.java
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-audiovideo-module/src/test/java/org/apache/tika/parser/ogg/OggAudioParserTest.java
index d40a8f52ae..610d09e164 100644
---
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-audiovideo-module/src/test/java/org/apache/tika/parser/ogg/OggAudioParserTest.java
+++
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-audiovideo-module/src/test/java/org/apache/tika/parser/ogg/OggAudioParserTest.java
@@ -113,19 +113,66 @@ public class OggAudioParserTest {
*/
@Test
public void testMetadataBlockPictureBecomesEmbeddedDocument() throws
Exception {
+ String block = pictureBlock(4, "Back cover");//picture type: cover
(back)
+
+ List<Metadata> pictures = new ArrayList<>();
+ ParseContext context = collectingContext(pictures);
+
+ Metadata metadata = extractComments(context, "metadata_block_picture",
block);
+
+ assertEquals(1, pictures.size());
+ Metadata pictureMetadata = pictures.get(0);
+ assertEquals("image/jpeg",
pictureMetadata.get(HttpHeaders.CONTENT_TYPE));
+ //the only picture is the file's thumbnail, front cover or not
+
assertEquals(TikaCoreProperties.EmbeddedResourceType.THUMBNAIL.toString(),
+
pictureMetadata.get(TikaCoreProperties.EMBEDDED_RESOURCE_TYPE));
+ assertEquals("Back cover",
pictureMetadata.get(TikaCoreProperties.TITLE));
+ assertEquals("Cover (back)",
pictureMetadata.get(TikaCoreProperties.DESCRIPTION));
+ assertNull(metadata.get("vorbis:metadata_block_picture"));
+ }
+
+ /**
+ * The front cover is the thumbnail even when another picture comes
+ * first; the others are inline pictures.
+ */
+ @Test
+ public void testFrontCoverIsTheThumbnail() throws Exception {
+ List<Metadata> pictures = new ArrayList<>();
+ ParseContext context = collectingContext(pictures);
+
+ extractComments(context, "metadata_block_picture", pictureBlock(4,
"Back cover"),
+ "metadata_block_picture", pictureBlock(3, "Front cover"),
+ "metadata_block_picture", pictureBlock(8, "Artist"));
+
+ assertEquals(3, pictures.size());
+ assertEquals(TikaCoreProperties.EmbeddedResourceType.INLINE.toString(),
+
pictures.get(0).get(TikaCoreProperties.EMBEDDED_RESOURCE_TYPE));
+ assertEquals("Front cover",
pictures.get(1).get(TikaCoreProperties.TITLE));
+
assertEquals(TikaCoreProperties.EmbeddedResourceType.THUMBNAIL.toString(),
+
pictures.get(1).get(TikaCoreProperties.EMBEDDED_RESOURCE_TYPE));
+ assertEquals(TikaCoreProperties.EmbeddedResourceType.INLINE.toString(),
+
pictures.get(2).get(TikaCoreProperties.EMBEDDED_RESOURCE_TYPE));
+ }
+
+ /**
+ * Builds a base64 metadata_block_picture comment holding a jpeg of the
+ * given picture type and description.
+ */
+ private static String pictureBlock(int pictureType, String
descriptionText) {
byte[] pictureData = new byte[]{1, 2, 3, 4};
byte[] mime = "image/jpeg".getBytes(StandardCharsets.ISO_8859_1);
- byte[] description = "Back cover".getBytes(StandardCharsets.UTF_8);
+ byte[] description = descriptionText.getBytes(StandardCharsets.UTF_8);
ByteBuffer buffer = ByteBuffer.allocate(4 + 4 + mime.length + 4 +
description.length +
16 + 4 + pictureData.length);
- buffer.putInt(4);//picture type: cover (back)
+ buffer.putInt(pictureType);
buffer.putInt(mime.length).put(mime);
buffer.putInt(description.length).put(description);
buffer.putInt(1).putInt(1).putInt(24).putInt(0);//width, height,
depth, colors
buffer.putInt(pictureData.length).put(pictureData);
- String block = Base64.getEncoder().encodeToString(buffer.array());
+ return Base64.getEncoder().encodeToString(buffer.array());
+ }
- List<Metadata> pictures = new ArrayList<>();
+ private static ParseContext collectingContext(List<Metadata> pictures) {
ParseContext context = new ParseContext();
context.set(EmbeddedDocumentExtractor.class, new
EmbeddedDocumentExtractor() {
@Override
@@ -139,17 +186,7 @@ public class OggAudioParserTest {
pictures.add(metadata);
}
});
-
- Metadata metadata = extractComments(context, "metadata_block_picture",
block);
-
- assertEquals(1, pictures.size());
- Metadata pictureMetadata = pictures.get(0);
- assertEquals("image/jpeg",
pictureMetadata.get(HttpHeaders.CONTENT_TYPE));
- assertEquals(TikaCoreProperties.EmbeddedResourceType.INLINE.toString(),
-
pictureMetadata.get(TikaCoreProperties.EMBEDDED_RESOURCE_TYPE));
- assertEquals("Back cover",
pictureMetadata.get(TikaCoreProperties.TITLE));
- assertEquals("Cover (back)",
pictureMetadata.get(TikaCoreProperties.DESCRIPTION));
- assertNull(metadata.get("vorbis:metadata_block_picture"));
+ return context;
}
private static Metadata extractComments(String... keysAndValues) throws
Exception {
@@ -173,7 +210,10 @@ public class OggAudioParserTest {
Metadata metadata = new Metadata();
XHTMLContentHandler xhtml = new XHTMLContentHandler(new
DefaultHandler(), metadata);
xhtml.startDocument();
- OggAudioParser.extractComments(metadata, xhtml, comments, context);
+ //emit the returned pictures, as the parsers do
+ OggAudioParser.extractPictures(
+ OggAudioParser.extractComments(metadata, xhtml, comments),
+ xhtml, context);
xhtml.endDocument();
return metadata;
}
diff --git
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-audiovideo-module/src/test/java/org/apache/tika/parser/ogg/VorbisParserTest.java
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-audiovideo-module/src/test/java/org/apache/tika/parser/ogg/VorbisParserTest.java
index 968e5aee8d..39e071ec65 100644
---
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-audiovideo-module/src/test/java/org/apache/tika/parser/ogg/VorbisParserTest.java
+++
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-audiovideo-module/src/test/java/org/apache/tika/parser/ogg/VorbisParserTest.java
@@ -70,7 +70,7 @@ public class VorbisParserTest extends TikaTest {
Metadata pictureMetadata = metadataList.get(1);
assertEquals("image/png",
pictureMetadata.get(HttpHeaders.CONTENT_TYPE));
- assertEquals(TikaCoreProperties.EmbeddedResourceType.INLINE.toString(),
+
assertEquals(TikaCoreProperties.EmbeddedResourceType.THUMBNAIL.toString(),
pictureMetadata.get(TikaCoreProperties.EMBEDDED_RESOURCE_TYPE));
assertEquals("Test Cover",
pictureMetadata.get(TikaCoreProperties.TITLE));
assertEquals("Cover (front)",
pictureMetadata.get(TikaCoreProperties.DESCRIPTION));
@@ -91,11 +91,15 @@ public class VorbisParserTest extends TikaTest {
assertEquals("image/png", front.get(HttpHeaders.CONTENT_TYPE));
assertEquals("Front Cover", front.get(TikaCoreProperties.TITLE));
assertEquals("Cover (front)",
front.get(TikaCoreProperties.DESCRIPTION));
+
assertEquals(TikaCoreProperties.EmbeddedResourceType.THUMBNAIL.toString(),
+ front.get(TikaCoreProperties.EMBEDDED_RESOURCE_TYPE));
Metadata back = metadataList.get(2);
assertEquals("image/png", back.get(HttpHeaders.CONTENT_TYPE));
assertEquals("Back Cover", back.get(TikaCoreProperties.TITLE));
assertEquals("Cover (back)", back.get(TikaCoreProperties.DESCRIPTION));
+ assertEquals(TikaCoreProperties.EmbeddedResourceType.INLINE.toString(),
+ back.get(TikaCoreProperties.EMBEDDED_RESOURCE_TYPE));
}
private static Metadata extractInfo(int upper, int nominal, int lower)
throws Exception {
diff --git
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-audiovideo-module/src/test/resources/test-documents/testFLAC_commentAndNativePicture.flac
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-audiovideo-module/src/test/resources/test-documents/testFLAC_commentAndNativePicture.flac
new file mode 100644
index 0000000000..2ca6825696
Binary files /dev/null and
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-audiovideo-module/src/test/resources/test-documents/testFLAC_commentAndNativePicture.flac
differ