summaryrefslogtreecommitdiffstats
path: root/src/com/android/camera/util/XmpUtil.java
diff options
context:
space:
mode:
Diffstat (limited to 'src/com/android/camera/util/XmpUtil.java')
-rw-r--r--src/com/android/camera/util/XmpUtil.java405
1 files changed, 405 insertions, 0 deletions
diff --git a/src/com/android/camera/util/XmpUtil.java b/src/com/android/camera/util/XmpUtil.java
new file mode 100644
index 000000000..c985a6bd8
--- /dev/null
+++ b/src/com/android/camera/util/XmpUtil.java
@@ -0,0 +1,405 @@
+/*
+ * Copyright (C) 2013 The Android Open Source Project
+ *
+ * Licensed 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 com.android.camera.util;
+
+import android.util.Log;
+
+import com.adobe.xmp.XMPException;
+import com.adobe.xmp.XMPMeta;
+import com.adobe.xmp.XMPMetaFactory;
+import com.adobe.xmp.options.SerializeOptions;
+
+import java.io.FileInputStream;
+import java.io.FileNotFoundException;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.io.UnsupportedEncodingException;
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Util class to read/write xmp from a jpeg image file. It only supports jpeg
+ * image format, and doesn't support extended xmp now.
+ * To use it:
+ * XMPMeta xmpMeta = XmpUtil.extractOrCreateXMPMeta(filename);
+ * xmpMeta.setProperty(PanoConstants.GOOGLE_PANO_NAMESPACE, "property_name", "value");
+ * XmpUtil.writeXMPMeta(filename, xmpMeta);
+ *
+ * Or if you don't care the existing XMP meta data in image file:
+ * XMPMeta xmpMeta = XmpUtil.createXMPMeta();
+ * xmpMeta.setPropertyBoolean(PanoConstants.GOOGLE_PANO_NAMESPACE, "bool_property_name", "true");
+ * XmpUtil.writeXMPMeta(filename, xmpMeta);
+ */
+public class XmpUtil {
+ private static final String TAG = "XmpUtil";
+ private static final int XMP_HEADER_SIZE = 29;
+ private static final String XMP_HEADER = "http://ns.adobe.com/xap/1.0/\0";
+ private static final int MAX_XMP_BUFFER_SIZE = 65502;
+
+ private static final String GOOGLE_PANO_NAMESPACE = "http://ns.google.com/photos/1.0/panorama/";
+ private static final String PANO_PREFIX = "GPano";
+
+ private static final int M_SOI = 0xd8; // File start marker.
+ private static final int M_APP1 = 0xe1; // Marker for Exif or XMP.
+ private static final int M_SOS = 0xda; // Image data marker.
+
+ // Jpeg file is composed of many sections and image data. This class is used
+ // to hold the section data from image file.
+ private static class Section {
+ public int marker;
+ public int length;
+ public byte[] data;
+ }
+
+ static {
+ try {
+ XMPMetaFactory.getSchemaRegistry().registerNamespace(
+ GOOGLE_PANO_NAMESPACE, PANO_PREFIX);
+ } catch (XMPException e) {
+ e.printStackTrace();
+ }
+ }
+
+ /**
+ * Extracts XMPMeta from JPEG image file.
+ *
+ * @param filename JPEG image file name.
+ * @return Extracted XMPMeta or null.
+ */
+ public static XMPMeta extractXMPMeta(String filename) {
+ if (!filename.toLowerCase().endsWith(".jpg")
+ && !filename.toLowerCase().endsWith(".jpeg")) {
+ Log.d(TAG, "XMP parse: only jpeg file is supported");
+ return null;
+ }
+
+ try {
+ return extractXMPMeta(new FileInputStream(filename));
+ } catch (FileNotFoundException e) {
+ Log.e(TAG, "Could not read file: " + filename, e);
+ return null;
+ }
+ }
+
+ /**
+ * Extracts XMPMeta from a JPEG image file stream.
+ *
+ * @param is the input stream containing the JPEG image file.
+ * @return Extracted XMPMeta or null.
+ */
+ public static XMPMeta extractXMPMeta(InputStream is) {
+ List<Section> sections = parse(is, true);
+ if (sections == null) {
+ return null;
+ }
+ // Now we don't support extended xmp.
+ for (Section section : sections) {
+ if (hasXMPHeader(section.data)) {
+ int end = getXMPContentEnd(section.data);
+ byte[] buffer = new byte[end - XMP_HEADER_SIZE];
+ System.arraycopy(
+ section.data, XMP_HEADER_SIZE, buffer, 0, buffer.length);
+ try {
+ XMPMeta result = XMPMetaFactory.parseFromBuffer(buffer);
+ return result;
+ } catch (XMPException e) {
+ Log.d(TAG, "XMP parse error", e);
+ return null;
+ }
+ }
+ }
+ return null;
+ }
+
+ /**
+ * Creates a new XMPMeta.
+ */
+ public static XMPMeta createXMPMeta() {
+ return XMPMetaFactory.create();
+ }
+
+ /**
+ * Tries to extract XMP meta from image file first, if failed, create one.
+ */
+ public static XMPMeta extractOrCreateXMPMeta(String filename) {
+ XMPMeta meta = extractXMPMeta(filename);
+ return meta == null ? createXMPMeta() : meta;
+ }
+
+ /**
+ * Writes the XMPMeta to the jpeg image file.
+ */
+ public static boolean writeXMPMeta(String filename, XMPMeta meta) {
+ if (!filename.toLowerCase().endsWith(".jpg")
+ && !filename.toLowerCase().endsWith(".jpeg")) {
+ Log.d(TAG, "XMP parse: only jpeg file is supported");
+ return false;
+ }
+ List<Section> sections = null;
+ try {
+ sections = parse(new FileInputStream(filename), false);
+ sections = insertXMPSection(sections, meta);
+ if (sections == null) {
+ return false;
+ }
+ } catch (FileNotFoundException e) {
+ Log.e(TAG, "Could not read file: " + filename, e);
+ return false;
+ }
+ FileOutputStream os = null;
+ try {
+ // Overwrite the image file with the new meta data.
+ os = new FileOutputStream(filename);
+ writeJpegFile(os, sections);
+ } catch (IOException e) {
+ Log.d(TAG, "Write file failed:" + filename, e);
+ return false;
+ } finally {
+ if (os != null) {
+ try {
+ os.close();
+ } catch (IOException e) {
+ // Ignore.
+ }
+ }
+ }
+ return true;
+ }
+
+ /**
+ * Updates a jpeg file from inputStream with XMPMeta to outputStream.
+ */
+ public static boolean writeXMPMeta(InputStream inputStream, OutputStream outputStream,
+ XMPMeta meta) {
+ List<Section> sections = parse(inputStream, false);
+ sections = insertXMPSection(sections, meta);
+ if (sections == null) {
+ return false;
+ }
+ try {
+ // Overwrite the image file with the new meta data.
+ writeJpegFile(outputStream, sections);
+ } catch (IOException e) {
+ Log.d(TAG, "Write to stream failed", e);
+ return false;
+ } finally {
+ if (outputStream != null) {
+ try {
+ outputStream.close();
+ } catch (IOException e) {
+ // Ignore.
+ }
+ }
+ }
+ return true;
+ }
+
+ /**
+ * Write a list of sections to a Jpeg file.
+ */
+ private static void writeJpegFile(OutputStream os, List<Section> sections)
+ throws IOException {
+ // Writes the jpeg file header.
+ os.write(0xff);
+ os.write(M_SOI);
+ for (Section section : sections) {
+ os.write(0xff);
+ os.write(section.marker);
+ if (section.length > 0) {
+ // It's not the image data.
+ int lh = section.length >> 8;
+ int ll = section.length & 0xff;
+ os.write(lh);
+ os.write(ll);
+ }
+ os.write(section.data);
+ }
+ }
+
+ private static List<Section> insertXMPSection(
+ List<Section> sections, XMPMeta meta) {
+ if (sections == null || sections.size() <= 1) {
+ return null;
+ }
+ byte[] buffer;
+ try {
+ SerializeOptions options = new SerializeOptions();
+ options.setUseCompactFormat(true);
+ // We have to omit packet wrapper here because
+ // javax.xml.parsers.DocumentBuilder
+ // fails to parse the packet end <?xpacket end="w"?> in android.
+ options.setOmitPacketWrapper(true);
+ buffer = XMPMetaFactory.serializeToBuffer(meta, options);
+ } catch (XMPException e) {
+ Log.d(TAG, "Serialize xmp failed", e);
+ return null;
+ }
+ if (buffer.length > MAX_XMP_BUFFER_SIZE) {
+ // Do not support extended xmp now.
+ return null;
+ }
+ // The XMP section starts with XMP_HEADER and then the real xmp data.
+ byte[] xmpdata = new byte[buffer.length + XMP_HEADER_SIZE];
+ System.arraycopy(XMP_HEADER.getBytes(), 0, xmpdata, 0, XMP_HEADER_SIZE);
+ System.arraycopy(buffer, 0, xmpdata, XMP_HEADER_SIZE, buffer.length);
+ Section xmpSection = new Section();
+ xmpSection.marker = M_APP1;
+ // Adds the length place (2 bytes) to the section length.
+ xmpSection.length = xmpdata.length + 2;
+ xmpSection.data = xmpdata;
+
+ for (int i = 0; i < sections.size(); ++i) {
+ // If we can find the old xmp section, replace it with the new one.
+ if (sections.get(i).marker == M_APP1
+ && hasXMPHeader(sections.get(i).data)) {
+ // Replace with the new xmp data.
+ sections.set(i, xmpSection);
+ return sections;
+ }
+ }
+ // If the first section is Exif, insert XMP data before the second section,
+ // otherwise, make xmp data the first section.
+ List<Section> newSections = new ArrayList<Section>();
+ int position = (sections.get(0).marker == M_APP1) ? 1 : 0;
+ newSections.addAll(sections.subList(0, position));
+ newSections.add(xmpSection);
+ newSections.addAll(sections.subList(position, sections.size()));
+ return newSections;
+ }
+
+ /**
+ * Checks whether the byte array has XMP header. The XMP section contains
+ * a fixed length header XMP_HEADER.
+ *
+ * @param data Xmp metadata.
+ */
+ private static boolean hasXMPHeader(byte[] data) {
+ if (data.length < XMP_HEADER_SIZE) {
+ return false;
+ }
+ try {
+ byte[] header = new byte[XMP_HEADER_SIZE];
+ System.arraycopy(data, 0, header, 0, XMP_HEADER_SIZE);
+ if (new String(header, "UTF-8").equals(XMP_HEADER)) {
+ return true;
+ }
+ } catch (UnsupportedEncodingException e) {
+ return false;
+ }
+ return false;
+ }
+
+ /**
+ * Gets the end of the xmp meta content. If there is no packet wrapper,
+ * return data.length, otherwise return 1 + the position of last '>'
+ * without '?' before it.
+ * Usually the packet wrapper end is "<?xpacket end="w"?> but
+ * javax.xml.parsers.DocumentBuilder fails to parse it in android.
+ *
+ * @param data xmp metadata bytes.
+ * @return The end of the xmp metadata content.
+ */
+ private static int getXMPContentEnd(byte[] data) {
+ for (int i = data.length - 1; i >= 1; --i) {
+ if (data[i] == '>') {
+ if (data[i - 1] != '?') {
+ return i + 1;
+ }
+ }
+ }
+ // It should not reach here for a valid xmp meta.
+ return data.length;
+ }
+
+ /**
+ * Parses the jpeg image file. If readMetaOnly is true, only keeps the Exif
+ * and XMP sections (with marker M_APP1) and ignore others; otherwise, keep
+ * all sections. The last section with image data will have -1 length.
+ *
+ * @param is Input image data stream.
+ * @param readMetaOnly Whether only reads the metadata in jpg.
+ * @return The parse result.
+ */
+ private static List<Section> parse(InputStream is, boolean readMetaOnly) {
+ try {
+ if (is.read() != 0xff || is.read() != M_SOI) {
+ return null;
+ }
+ List<Section> sections = new ArrayList<Section>();
+ int c;
+ while ((c = is.read()) != -1) {
+ if (c != 0xff) {
+ return null;
+ }
+ // Skip padding bytes.
+ while ((c = is.read()) == 0xff) {
+ }
+ if (c == -1) {
+ return null;
+ }
+ int marker = c;
+ if (marker == M_SOS) {
+ // M_SOS indicates the image data will follow and no metadata after
+ // that, so read all data at one time.
+ if (!readMetaOnly) {
+ Section section = new Section();
+ section.marker = marker;
+ section.length = -1;
+ section.data = new byte[is.available()];
+ is.read(section.data, 0, section.data.length);
+ sections.add(section);
+ }
+ return sections;
+ }
+ int lh = is.read();
+ int ll = is.read();
+ if (lh == -1 || ll == -1) {
+ return null;
+ }
+ int length = lh << 8 | ll;
+ if (!readMetaOnly || c == M_APP1) {
+ Section section = new Section();
+ section.marker = marker;
+ section.length = length;
+ section.data = new byte[length - 2];
+ is.read(section.data, 0, length - 2);
+ sections.add(section);
+ } else {
+ // Skip this section since all exif/xmp meta will be in M_APP1
+ // section.
+ is.skip(length - 2);
+ }
+ }
+ return sections;
+ } catch (IOException e) {
+ Log.d(TAG, "Could not parse file.", e);
+ return null;
+ } finally {
+ if (is != null) {
+ try {
+ is.close();
+ } catch (IOException e) {
+ // Ignore.
+ }
+ }
+ }
+ }
+
+ private XmpUtil() {}
+}