HeifXmp.java
/*
* 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 java.nio.charset.StandardCharsets.US_ASCII;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.IOException;
import java.io.RandomAccessFile;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import com.drew.lang.SequentialByteArrayReader;
import com.drew.metadata.heif.boxes.Box;
import com.drew.metadata.heif.boxes.ItemInfoBox;
import com.drew.metadata.heif.boxes.ItemInfoBox.ItemInfoEntry;
import com.drew.metadata.heif.boxes.ItemLocationBox;
import com.drew.metadata.heif.boxes.ItemLocationBox.Extent;
import org.xml.sax.SAXException;
import org.apache.tika.exception.TikaException;
import org.apache.tika.extractor.EmbeddedDocumentUtil;
import org.apache.tika.metadata.Metadata;
import org.apache.tika.parser.ParseContext;
import org.apache.tika.parser.xmp.XmpExtractor;
/**
* HEIF/HEIC XMP fallback for packets that are <em>not</em> {@code <?xpacket?>}-wrapped (the byte
* scanner {@link ImageXmp#scanAndExtract} can't find those). XMP is a
* {@code mime}/{@code application/rdf+xml} item located via the ISO-BMFF
* {@code meta}/{@code iinf}/{@code iloc} boxes, parsed with metadata-extractor's box classes; only
* the top-level box walk is ours. No content-type getter exists, so a mime item is confirmed by
* sniffing its bytes for XMP markers.
*/
final class HeifXmp {
private HeifXmp() {
}
// 64 MB: guards against a corrupt extent length triggering a huge allocation.
private static final long MAX_ITEM = 64L * 1024 * 1024;
/** Locate the rdf+xml item and parse it; returns false if none was found. */
static boolean extract(File file, Metadata metadata, ParseContext context) {
byte[] xmp = locateQuietly(file);
if (xmp == null) {
return false;
}
try {
new XmpExtractor().extract(xmp, metadata, context);
} catch (SecurityException e) {
throw e;
} catch (IOException | SAXException | TikaException | RuntimeException e) {
EmbeddedDocumentUtil.recordException(e, metadata); // bad XMP must not fail the parse
}
return true;
}
private static byte[] locateQuietly(File file) {
try {
return locate(file);
} catch (SecurityException e) {
throw e;
} catch (IOException | RuntimeException e) {
return null; // malformed / unreadable box structure -> no XMP, let EXIF proceed
}
}
private static byte[] locate(File file) throws IOException {
try (RandomAccessFile raf = new RandomAccessFile(file, "r")) {
long len = raf.length();
long[] meta = box(raf, 0, len, "meta");
if (meta == null) {
return null;
}
// meta is a FullBox: skip its 4-byte version/flags to reach the child boxes.
long childStart = meta[1] + 4;
long[] iinfAt = box(raf, childStart, meta[2], "iinf");
long[] ilocAt = box(raf, childStart, meta[2], "iloc");
if (iinfAt == null || ilocAt == null) {
return null;
}
ItemInfoBox iinf = parseIinf(readBytes(raf, iinfAt[0], (int) (iinfAt[2] - iinfAt[0])));
ItemLocationBox iloc = parseIloc(readBytes(raf, ilocAt[0], (int) (ilocAt[2] - ilocAt[0])));
return readXmpItem(raf, iinf, iloc, len);
}
}
/** First box of {@code type} within [start,end); returns {boxStart, payloadStart, boxEnd}. */
private static long[] box(RandomAccessFile raf, long start, long end, String type)
throws IOException {
long p = start;
while (p + 8 <= end) {
raf.seek(p);
long size = raf.readInt() & 0xffffffffL;
byte[] t = new byte[4];
raf.readFully(t);
long headerLen = 8;
if (size == 1) {
size = raf.readLong();
headerLen = 16;
} else if (size == 0) {
size = end - p;
}
if (size < headerLen || p + size > end) {
return null; // corrupt/truncated
}
if (type.equals(new String(t, US_ASCII))) {
return new long[]{p, p + headerLen, p + size};
}
p += size;
}
return null;
}
private static ItemInfoBox parseIinf(byte[] bytes) throws IOException {
SequentialByteArrayReader r = new SequentialByteArrayReader(bytes);
return new ItemInfoBox(r, new Box(r));
}
private static ItemLocationBox parseIloc(byte[] bytes) throws IOException {
SequentialByteArrayReader r = new SequentialByteArrayReader(bytes);
return new ItemLocationBox(r, new Box(r));
}
private static byte[] readXmpItem(RandomAccessFile raf, ItemInfoBox iinf, ItemLocationBox iloc,
long fileLen) throws IOException {
Map<Long, List<Extent>> byItem = new LinkedHashMap<>();
for (Extent e : iloc.getExtents()) {
byItem.computeIfAbsent(e.getItemId(), k -> new ArrayList<>()).add(e);
}
for (Map.Entry<Long, List<Extent>> item : byItem.entrySet()) {
ItemInfoEntry info = iinf.getEntry(item.getKey());
if (info == null || !"mime".equals(info.getItemType())) {
continue;
}
byte[] data = readExtents(raf, item.getValue(), fileLen);
if (data != null && looksLikeXmp(data)) {
return data;
}
}
return null;
}
private static byte[] readExtents(RandomAccessFile raf, List<Extent> extents, long fileLen)
throws IOException {
// Extent.getOffset() is an absolute file offset (iloc construction_method 0, which every real
// XMP item uses). Methods 1/2 aren't resolved -- like metadata-extractor's Exif reader -- but
// that's a safe miss: a wrong offset fails the bounds check or yields bytes looksLikeXmp()
// rejects, so the item is skipped. Resolve the base offset if a real method-1/2 file appears.
long total = 0;
for (Extent e : extents) {
if (e.getOffset() < 0 || e.getLength() < 0 || e.getOffset() + e.getLength() > fileLen) {
return null;
}
total += e.getLength();
if (total > MAX_ITEM) {
return null;
}
}
ByteArrayOutputStream out = new ByteArrayOutputStream((int) total);
for (Extent e : extents) {
out.write(readBytes(raf, e.getOffset(), (int) e.getLength()));
}
return out.toByteArray();
}
private static byte[] readBytes(RandomAccessFile raf, long pos, int len) throws IOException {
byte[] b = new byte[len];
raf.seek(pos);
raf.readFully(b);
return b;
}
private static boolean looksLikeXmp(byte[] data) {
String head = new String(data, 0, Math.min(data.length, 8192), US_ASCII);
return head.contains("xmpmeta") || head.contains("rdf:RDF") || head.contains("xpacket");
}
}