blob: c985a6bd82cb222edb3c99352e7df7feb23713bc [file] [log] [blame]
Sascha Haeberlinga5a08d72013-09-11 20:30:52 -07001/*
2 * Copyright (C) 2013 The Android Open Source Project
3 *
4 * Licensed under the Apache License, Version 2.0 (the "License");
5 * you may not use this file except in compliance with the License.
6 * You may obtain a copy of the License at
7 *
8 * http://www.apache.org/licenses/LICENSE-2.0
9 *
10 * Unless required by applicable law or agreed to in writing, software
11 * distributed under the License is distributed on an "AS IS" BASIS,
12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 * See the License for the specific language governing permissions and
14 * limitations under the License.
15 */
16
17package com.android.camera.util;
18
19import android.util.Log;
20
21import com.adobe.xmp.XMPException;
22import com.adobe.xmp.XMPMeta;
23import com.adobe.xmp.XMPMetaFactory;
24import com.adobe.xmp.options.SerializeOptions;
25
26import java.io.FileInputStream;
27import java.io.FileNotFoundException;
28import java.io.FileOutputStream;
29import java.io.IOException;
30import java.io.InputStream;
31import java.io.OutputStream;
32import java.io.UnsupportedEncodingException;
33import java.util.ArrayList;
34import java.util.List;
35
36/**
37 * Util class to read/write xmp from a jpeg image file. It only supports jpeg
38 * image format, and doesn't support extended xmp now.
39 * To use it:
40 * XMPMeta xmpMeta = XmpUtil.extractOrCreateXMPMeta(filename);
41 * xmpMeta.setProperty(PanoConstants.GOOGLE_PANO_NAMESPACE, "property_name", "value");
42 * XmpUtil.writeXMPMeta(filename, xmpMeta);
43 *
44 * Or if you don't care the existing XMP meta data in image file:
45 * XMPMeta xmpMeta = XmpUtil.createXMPMeta();
46 * xmpMeta.setPropertyBoolean(PanoConstants.GOOGLE_PANO_NAMESPACE, "bool_property_name", "true");
47 * XmpUtil.writeXMPMeta(filename, xmpMeta);
48 */
49public class XmpUtil {
50 private static final String TAG = "XmpUtil";
51 private static final int XMP_HEADER_SIZE = 29;
52 private static final String XMP_HEADER = "http://ns.adobe.com/xap/1.0/\0";
53 private static final int MAX_XMP_BUFFER_SIZE = 65502;
54
55 private static final String GOOGLE_PANO_NAMESPACE = "http://ns.google.com/photos/1.0/panorama/";
56 private static final String PANO_PREFIX = "GPano";
57
58 private static final int M_SOI = 0xd8; // File start marker.
59 private static final int M_APP1 = 0xe1; // Marker for Exif or XMP.
60 private static final int M_SOS = 0xda; // Image data marker.
61
62 // Jpeg file is composed of many sections and image data. This class is used
63 // to hold the section data from image file.
64 private static class Section {
65 public int marker;
66 public int length;
67 public byte[] data;
68 }
69
70 static {
71 try {
72 XMPMetaFactory.getSchemaRegistry().registerNamespace(
73 GOOGLE_PANO_NAMESPACE, PANO_PREFIX);
74 } catch (XMPException e) {
75 e.printStackTrace();
76 }
77 }
78
79 /**
80 * Extracts XMPMeta from JPEG image file.
81 *
82 * @param filename JPEG image file name.
83 * @return Extracted XMPMeta or null.
84 */
85 public static XMPMeta extractXMPMeta(String filename) {
86 if (!filename.toLowerCase().endsWith(".jpg")
87 && !filename.toLowerCase().endsWith(".jpeg")) {
88 Log.d(TAG, "XMP parse: only jpeg file is supported");
89 return null;
90 }
91
92 try {
93 return extractXMPMeta(new FileInputStream(filename));
94 } catch (FileNotFoundException e) {
95 Log.e(TAG, "Could not read file: " + filename, e);
96 return null;
97 }
98 }
99
100 /**
101 * Extracts XMPMeta from a JPEG image file stream.
102 *
103 * @param is the input stream containing the JPEG image file.
104 * @return Extracted XMPMeta or null.
105 */
106 public static XMPMeta extractXMPMeta(InputStream is) {
107 List<Section> sections = parse(is, true);
108 if (sections == null) {
109 return null;
110 }
111 // Now we don't support extended xmp.
112 for (Section section : sections) {
113 if (hasXMPHeader(section.data)) {
114 int end = getXMPContentEnd(section.data);
115 byte[] buffer = new byte[end - XMP_HEADER_SIZE];
116 System.arraycopy(
117 section.data, XMP_HEADER_SIZE, buffer, 0, buffer.length);
118 try {
119 XMPMeta result = XMPMetaFactory.parseFromBuffer(buffer);
120 return result;
121 } catch (XMPException e) {
122 Log.d(TAG, "XMP parse error", e);
123 return null;
124 }
125 }
126 }
127 return null;
128 }
129
130 /**
131 * Creates a new XMPMeta.
132 */
133 public static XMPMeta createXMPMeta() {
134 return XMPMetaFactory.create();
135 }
136
137 /**
138 * Tries to extract XMP meta from image file first, if failed, create one.
139 */
140 public static XMPMeta extractOrCreateXMPMeta(String filename) {
141 XMPMeta meta = extractXMPMeta(filename);
142 return meta == null ? createXMPMeta() : meta;
143 }
144
145 /**
146 * Writes the XMPMeta to the jpeg image file.
147 */
148 public static boolean writeXMPMeta(String filename, XMPMeta meta) {
149 if (!filename.toLowerCase().endsWith(".jpg")
150 && !filename.toLowerCase().endsWith(".jpeg")) {
151 Log.d(TAG, "XMP parse: only jpeg file is supported");
152 return false;
153 }
154 List<Section> sections = null;
155 try {
156 sections = parse(new FileInputStream(filename), false);
157 sections = insertXMPSection(sections, meta);
158 if (sections == null) {
159 return false;
160 }
161 } catch (FileNotFoundException e) {
162 Log.e(TAG, "Could not read file: " + filename, e);
163 return false;
164 }
165 FileOutputStream os = null;
166 try {
167 // Overwrite the image file with the new meta data.
168 os = new FileOutputStream(filename);
169 writeJpegFile(os, sections);
170 } catch (IOException e) {
171 Log.d(TAG, "Write file failed:" + filename, e);
172 return false;
173 } finally {
174 if (os != null) {
175 try {
176 os.close();
177 } catch (IOException e) {
178 // Ignore.
179 }
180 }
181 }
182 return true;
183 }
184
185 /**
186 * Updates a jpeg file from inputStream with XMPMeta to outputStream.
187 */
188 public static boolean writeXMPMeta(InputStream inputStream, OutputStream outputStream,
189 XMPMeta meta) {
190 List<Section> sections = parse(inputStream, false);
191 sections = insertXMPSection(sections, meta);
192 if (sections == null) {
193 return false;
194 }
195 try {
196 // Overwrite the image file with the new meta data.
197 writeJpegFile(outputStream, sections);
198 } catch (IOException e) {
199 Log.d(TAG, "Write to stream failed", e);
200 return false;
201 } finally {
202 if (outputStream != null) {
203 try {
204 outputStream.close();
205 } catch (IOException e) {
206 // Ignore.
207 }
208 }
209 }
210 return true;
211 }
212
213 /**
214 * Write a list of sections to a Jpeg file.
215 */
216 private static void writeJpegFile(OutputStream os, List<Section> sections)
217 throws IOException {
218 // Writes the jpeg file header.
219 os.write(0xff);
220 os.write(M_SOI);
221 for (Section section : sections) {
222 os.write(0xff);
223 os.write(section.marker);
224 if (section.length > 0) {
225 // It's not the image data.
226 int lh = section.length >> 8;
227 int ll = section.length & 0xff;
228 os.write(lh);
229 os.write(ll);
230 }
231 os.write(section.data);
232 }
233 }
234
235 private static List<Section> insertXMPSection(
236 List<Section> sections, XMPMeta meta) {
237 if (sections == null || sections.size() <= 1) {
238 return null;
239 }
240 byte[] buffer;
241 try {
242 SerializeOptions options = new SerializeOptions();
243 options.setUseCompactFormat(true);
244 // We have to omit packet wrapper here because
245 // javax.xml.parsers.DocumentBuilder
246 // fails to parse the packet end <?xpacket end="w"?> in android.
247 options.setOmitPacketWrapper(true);
248 buffer = XMPMetaFactory.serializeToBuffer(meta, options);
249 } catch (XMPException e) {
250 Log.d(TAG, "Serialize xmp failed", e);
251 return null;
252 }
253 if (buffer.length > MAX_XMP_BUFFER_SIZE) {
254 // Do not support extended xmp now.
255 return null;
256 }
257 // The XMP section starts with XMP_HEADER and then the real xmp data.
258 byte[] xmpdata = new byte[buffer.length + XMP_HEADER_SIZE];
259 System.arraycopy(XMP_HEADER.getBytes(), 0, xmpdata, 0, XMP_HEADER_SIZE);
260 System.arraycopy(buffer, 0, xmpdata, XMP_HEADER_SIZE, buffer.length);
261 Section xmpSection = new Section();
262 xmpSection.marker = M_APP1;
263 // Adds the length place (2 bytes) to the section length.
264 xmpSection.length = xmpdata.length + 2;
265 xmpSection.data = xmpdata;
266
267 for (int i = 0; i < sections.size(); ++i) {
268 // If we can find the old xmp section, replace it with the new one.
269 if (sections.get(i).marker == M_APP1
270 && hasXMPHeader(sections.get(i).data)) {
271 // Replace with the new xmp data.
272 sections.set(i, xmpSection);
273 return sections;
274 }
275 }
276 // If the first section is Exif, insert XMP data before the second section,
277 // otherwise, make xmp data the first section.
278 List<Section> newSections = new ArrayList<Section>();
279 int position = (sections.get(0).marker == M_APP1) ? 1 : 0;
280 newSections.addAll(sections.subList(0, position));
281 newSections.add(xmpSection);
282 newSections.addAll(sections.subList(position, sections.size()));
283 return newSections;
284 }
285
286 /**
287 * Checks whether the byte array has XMP header. The XMP section contains
288 * a fixed length header XMP_HEADER.
289 *
290 * @param data Xmp metadata.
291 */
292 private static boolean hasXMPHeader(byte[] data) {
293 if (data.length < XMP_HEADER_SIZE) {
294 return false;
295 }
296 try {
297 byte[] header = new byte[XMP_HEADER_SIZE];
298 System.arraycopy(data, 0, header, 0, XMP_HEADER_SIZE);
299 if (new String(header, "UTF-8").equals(XMP_HEADER)) {
300 return true;
301 }
302 } catch (UnsupportedEncodingException e) {
303 return false;
304 }
305 return false;
306 }
307
308 /**
309 * Gets the end of the xmp meta content. If there is no packet wrapper,
310 * return data.length, otherwise return 1 + the position of last '>'
311 * without '?' before it.
312 * Usually the packet wrapper end is "<?xpacket end="w"?> but
313 * javax.xml.parsers.DocumentBuilder fails to parse it in android.
314 *
315 * @param data xmp metadata bytes.
316 * @return The end of the xmp metadata content.
317 */
318 private static int getXMPContentEnd(byte[] data) {
319 for (int i = data.length - 1; i >= 1; --i) {
320 if (data[i] == '>') {
321 if (data[i - 1] != '?') {
322 return i + 1;
323 }
324 }
325 }
326 // It should not reach here for a valid xmp meta.
327 return data.length;
328 }
329
330 /**
331 * Parses the jpeg image file. If readMetaOnly is true, only keeps the Exif
332 * and XMP sections (with marker M_APP1) and ignore others; otherwise, keep
333 * all sections. The last section with image data will have -1 length.
334 *
335 * @param is Input image data stream.
336 * @param readMetaOnly Whether only reads the metadata in jpg.
337 * @return The parse result.
338 */
339 private static List<Section> parse(InputStream is, boolean readMetaOnly) {
340 try {
341 if (is.read() != 0xff || is.read() != M_SOI) {
342 return null;
343 }
344 List<Section> sections = new ArrayList<Section>();
345 int c;
346 while ((c = is.read()) != -1) {
347 if (c != 0xff) {
348 return null;
349 }
350 // Skip padding bytes.
351 while ((c = is.read()) == 0xff) {
352 }
353 if (c == -1) {
354 return null;
355 }
356 int marker = c;
357 if (marker == M_SOS) {
358 // M_SOS indicates the image data will follow and no metadata after
359 // that, so read all data at one time.
360 if (!readMetaOnly) {
361 Section section = new Section();
362 section.marker = marker;
363 section.length = -1;
364 section.data = new byte[is.available()];
365 is.read(section.data, 0, section.data.length);
366 sections.add(section);
367 }
368 return sections;
369 }
370 int lh = is.read();
371 int ll = is.read();
372 if (lh == -1 || ll == -1) {
373 return null;
374 }
375 int length = lh << 8 | ll;
376 if (!readMetaOnly || c == M_APP1) {
377 Section section = new Section();
378 section.marker = marker;
379 section.length = length;
380 section.data = new byte[length - 2];
381 is.read(section.data, 0, length - 2);
382 sections.add(section);
383 } else {
384 // Skip this section since all exif/xmp meta will be in M_APP1
385 // section.
386 is.skip(length - 2);
387 }
388 }
389 return sections;
390 } catch (IOException e) {
391 Log.d(TAG, "Could not parse file.", e);
392 return null;
393 } finally {
394 if (is != null) {
395 try {
396 is.close();
397 } catch (IOException e) {
398 // Ignore.
399 }
400 }
401 }
402 }
403
404 private XmpUtil() {}
405}