package de.jeisfeld.augendiagnoselib.util.imagefile; import java.io.BufferedOutputStream; import java.io.File; import java.io.FileOutputStream; import java.io.IOException; import java.io.OutputStream; import java.util.List; import android.media.ExifInterface; import android.support.annotation.NonNull; import android.support.annotation.Nullable; import android.util.Log; import com.adobe.xmp.XMPException; import de.jeisfeld.augendiagnoselib.Application; import de.jeisfeld.augendiagnoselib.R; import de.jeisfeld.augendiagnoselib.util.PreferenceUtil; import org.apache.commons.imaging.ImageReadException; import org.apache.commons.imaging.ImageWriteException; import org.apache.commons.imaging.Imaging; import org.apache.commons.imaging.common.IImageMetadata; import org.apache.commons.imaging.formats.jpeg.JpegImageMetadata; import org.apache.commons.imaging.formats.jpeg.exif.ExifRewriter; import org.apache.commons.imaging.formats.jpeg.xmp.JpegXmpRewriter; import org.apache.commons.imaging.formats.tiff.TiffField; import org.apache.commons.imaging.formats.tiff.TiffImageMetadata; import org.apache.commons.imaging.formats.tiff.constants.ExifTagConstants; import org.apache.commons.imaging.formats.tiff.constants.MicrosoftTagConstants; import org.apache.commons.imaging.formats.tiff.constants.TiffDirectoryType; import org.apache.commons.imaging.formats.tiff.constants.TiffTagConstants; import org.apache.commons.imaging.formats.tiff.taginfos.TagInfo; import org.apache.commons.imaging.formats.tiff.taginfos.TagInfoShort; import org.apache.commons.imaging.formats.tiff.write.TiffOutputDirectory; import org.apache.commons.imaging.formats.tiff.write.TiffOutputSet; import org.apache.commons.imaging.util.IoUtils; /** * Helper clase to retrieve and save metadata in a JPEG file. */ public final class JpegMetadataUtil { /** * Hide default constructor. */ private JpegMetadataUtil() { throw new UnsupportedOperationException(); } /** * Log all Exif data of the file. * * @param imageFile the image file. * @throws ImageReadException thrown if the metadata cannot be read. * @throws IOException thrown in case of other errors while reading metadata. */ public static void printAllExifData(@NonNull final File imageFile) throws ImageReadException, IOException { final IImageMetadata metadata = Imaging.getMetadata(imageFile); TiffImageMetadata tiffImageMetadata; if (metadata instanceof JpegImageMetadata) { tiffImageMetadata = ((JpegImageMetadata) metadata).getExif(); } else if (metadata instanceof TiffImageMetadata) { tiffImageMetadata = (TiffImageMetadata) metadata; } else { return; } @SuppressWarnings("unchecked") List<TiffImageMetadata.Item> items = (List<TiffImageMetadata.Item>) tiffImageMetadata.getItems(); for (TiffImageMetadata.Item item : items) { Log.i(Application.TAG, item.getTiffField().toString()); } } /** * Get the Exif date of an image file. * * @param imageFile the image file. * @return The EXIF date. * @throws ImageReadException thrown if the metadata cannot be read. * @throws IOException thrown in case of other errors while reading metadata. */ public static String getExifDate(@NonNull final File imageFile) throws ImageReadException, IOException { final IImageMetadata metadata = Imaging.getMetadata(imageFile); TiffImageMetadata tiffImageMetadata; if (metadata instanceof JpegImageMetadata) { tiffImageMetadata = ((JpegImageMetadata) metadata).getExif(); } else if (metadata instanceof TiffImageMetadata) { tiffImageMetadata = (TiffImageMetadata) metadata; } else { return null; } TiffField dateTime = tiffImageMetadata.findField(TiffTagConstants.TIFF_TAG_DATE_TIME); if (dateTime == null) { return null; } else { return dateTime.getStringValue(); } } /** * Retrieve the orientation of a file from the EXIF data. Required, as built-in ExifInterface is not always * reliable. * * @param imageFile the image file. * @return the orientation value. */ protected static int getExifOrientation(@NonNull final File imageFile) { try { final IImageMetadata metadata = Imaging.getMetadata(imageFile); TiffImageMetadata tiffImageMetadata; if (metadata instanceof JpegImageMetadata) { tiffImageMetadata = ((JpegImageMetadata) metadata).getExif(); } else if (metadata instanceof TiffImageMetadata) { tiffImageMetadata = (TiffImageMetadata) metadata; } else { return ExifInterface.ORIENTATION_UNDEFINED; } TiffField field = tiffImageMetadata.findField(TiffTagConstants.TIFF_TAG_ORIENTATION); if (field != null) { return field.getIntValue(); } else { TagInfo tagInfo = new TagInfoShort("Orientation", 274, 1, TiffDirectoryType.TIFF_DIRECTORY_IFD0); // MAGIC_NUMBER field = tiffImageMetadata.findField(tagInfo); if (field != null) { return field.getIntValue(); } else { return ExifInterface.ORIENTATION_UNDEFINED; } } } catch (Exception e) { return ExifInterface.ORIENTATION_UNDEFINED; } } /** * Log all XML data of the file. * * @param imageFile the file. * @throws ImageReadException thrown if the metadata cannot be read. * @throws IOException thrown in case of other errors while reading metadata. * @throws XMPException thrown in case of issues with XML handling. */ public static void printAllXmpData(@NonNull final File imageFile) throws ImageReadException, IOException, XMPException { final String xmpString = Imaging.getXmpXml(imageFile); Log.i(Application.TAG, new XmpHandler(xmpString).getXmpString()); } /** * Validate that the file is a JPEG file. * * @param jpegImageFileName the file to be validated. * @throws IOException thrown if the file is no jpg. */ public static void checkJpeg(@Nullable final String jpegImageFileName) throws IOException { if (jpegImageFileName == null) { throw new IOException("Error in checkJpeg - no image passed."); } File file = new File(jpegImageFileName); String mimeType; try { mimeType = Imaging.getImageInfo(file).getMimeType(); if (!"image/jpeg".equals(mimeType)) { throw new IOException("Bad MIME type " + mimeType + " - can handle metadata only for image/jpeg."); } } catch (ImageReadException e) { throw new IOException(e); } } /** * Retrieve the relevant metadata of an image file. * * @param jpegImageFileName the file for which metadata should be retrieved. * @return the metadata of the file. * @throws ImageReadException thrown if the metadata cannot be read. * @throws IOException thrown in case of other errors while reading metadata. */ @NonNull public static JpegMetadata getMetadata(@NonNull final String jpegImageFileName) throws ImageReadException, IOException { checkJpeg(jpegImageFileName); JpegMetadata result = new JpegMetadata(); final File imageFile = new File(jpegImageFileName); // Retrieve XMP data String xmpString = Imaging.getXmpXml(imageFile); XmpHandler parser = new XmpHandler(xmpString); // Standard fields are pre-filled with custom data result.setTitle(parser.getJeItem(XmpHandler.ITEM_TITLE)); result.setDescription(parser.getJeItem(XmpHandler.ITEM_DESCRIPTION)); result.setSubject(parser.getJeItem(XmpHandler.ITEM_SUBJECT)); result.setComment(parser.getJeItem(XmpHandler.ITEM_COMMENT)); result.setPerson(parser.getJeItem(XmpHandler.ITEM_PERSON)); result.setXCenter(parser.getJeItem(XmpHandler.ITEM_X_CENTER)); result.setYCenter(parser.getJeItem(XmpHandler.ITEM_Y_CENTER)); result.setOverlayScaleFactor(parser.getJeItem(XmpHandler.ITEM_OVERLAY_SCALE_FACTOR)); result.setXPosition(parser.getJeItem(XmpHandler.ITEM_X_POSITION)); result.setYPosition(parser.getJeItem(XmpHandler.ITEM_Y_POSITION)); result.setZoomFactor(parser.getJeItem(XmpHandler.ITEM_ZOOM_FACTOR)); result.setOrganizeDate(parser.getJeDate(XmpHandler.ITEM_ORGANIZE_DATE)); result.setRightLeft(parser.getJeItem(XmpHandler.ITEM_RIGHT_LEFT)); result.setBrightness(parser.getJeItem(XmpHandler.ITEM_BRIGHTNESS)); result.setContrast(parser.getJeItem(XmpHandler.ITEM_CONTRAST)); result.setSaturation(parser.getJeItem(XmpHandler.ITEM_SATURATION)); result.setColorTemperature(parser.getJeItem(XmpHandler.ITEM_COLOR_TEMPERATURE)); result.setOverlayColor(parser.getJeItem(XmpHandler.ITEM_OVERLAY_COLOR)); result.setPupilSize(parser.getJeItem(XmpHandler.ITEM_PUPIL_SIZE)); result.setPupilXOffset(parser.getJeItem(XmpHandler.ITEM_PUPIL_X_OFFSET)); result.setPupilYOffset(parser.getJeItem(XmpHandler.ITEM_PUPIL_Y_OFFSET)); result.setFlags(parser.getJeInt(XmpHandler.ITEM_FLAGS)); // For standard fields, use custom data only if there is no other data. if (result.getDescription() == null) { result.setDescription(parser.getDcDescription()); } if (result.getSubject() == null) { result.setSubject(parser.getDcSubject()); } if (result.getPerson() == null) { result.setPerson(parser.getMicrosoftPerson()); } if (result.getTitle() == null) { result.setTitle(parser.getDcTitle()); } if (result.getComment() == null) { result.setComment(parser.getUserComment()); } // Retrieve EXIF data try { final IImageMetadata metadata = Imaging.getMetadata(imageFile); TiffImageMetadata tiffImageMetadata; if (metadata instanceof JpegImageMetadata) { tiffImageMetadata = ((JpegImageMetadata) metadata).getExif(); } else if (metadata instanceof TiffImageMetadata) { tiffImageMetadata = (TiffImageMetadata) metadata; } else { return result; } // EXIF data have precedence only if saving EXIF is allowed TiffField title = tiffImageMetadata.findField(TiffTagConstants.TIFF_TAG_IMAGE_DESCRIPTION); if (title != null && (changeExifAllowed() || result.getTitle() == null)) { result.setTitle(title.getStringValue().trim()); } String exifComment = null; TiffField comment = tiffImageMetadata.findField(ExifTagConstants.EXIF_TAG_USER_COMMENT); if (comment != null && comment.getStringValue().trim().length() > 0) { exifComment = comment.getStringValue().trim(); } TiffField comment2 = tiffImageMetadata.findField(MicrosoftTagConstants.EXIF_TAG_XPCOMMENT); if (comment2 != null && comment2.getStringValue().trim().length() > 0) { // XPComment takes precedence if existing exifComment = comment2.getStringValue().trim(); } if (exifComment != null && (changeExifAllowed() || result.getComment() == null)) { result.setComment(exifComment); } TiffField subject = tiffImageMetadata.findField(MicrosoftTagConstants.EXIF_TAG_XPSUBJECT); if (subject != null && (changeExifAllowed() || result.getSubject() == null)) { result.setSubject(subject.getStringValue().trim()); } } catch (Exception e) { Log.w(Application.TAG, "Error when retrieving Exif data", e); } // If fields are still null, try to get them from custom XMP if (result.getDescription() == null) { result.setDescription(parser.getJeItem(XmpHandler.ITEM_DESCRIPTION)); } if (result.getSubject() == null) { result.setSubject(parser.getJeItem(XmpHandler.ITEM_SUBJECT)); } if (result.getPerson() == null) { result.setPerson(parser.getJeItem(XmpHandler.ITEM_PERSON)); } if (result.getTitle() == null) { result.setTitle(parser.getJeItem(XmpHandler.ITEM_TITLE)); } if (result.getComment() == null) { result.setComment(parser.getJeItem(XmpHandler.ITEM_COMMENT)); } return result; } /** * Change metadata of the image (EXIF and XMP as far as applicable). * * @param jpegImageFileName the file for which metadata should be changed. * @param metadata the new metadata. * @throws ImageReadException thrown if the metadata cannot be read. * @throws ImageWriteException thrown if the metadata cannot be written. * @throws IOException thrown in case of other errors while reading metadata. * @throws XMPException thrown in case of issues with XML handling. */ public static void changeMetadata(@NonNull final String jpegImageFileName, @NonNull final JpegMetadata metadata) throws IOException, ImageReadException, ImageWriteException, XMPException { if (changeJpegAllowed()) { checkJpeg(jpegImageFileName); changeXmpMetadata(jpegImageFileName, metadata); if (changeExifAllowed()) { try { changeExifMetadata(jpegImageFileName, metadata); } catch (Exception e) { throw new ExifStorageException(e); } } } } /** * Change the EXIF metadata. * * @param jpegImageFileName the file for which metadata should be changed. * @param metadata the new metadata * @throws ImageReadException thrown if the metadata cannot be read. * @throws ImageWriteException thrown if the metadata cannot be written. * @throws IOException thrown in case of other errors while reading metadata. */ @SuppressWarnings("resource") private static void changeExifMetadata(@NonNull final String jpegImageFileName, @NonNull final JpegMetadata metadata) throws IOException, ImageReadException, ImageWriteException { File jpegImageFile = new File(jpegImageFileName); File tempFile = FileUtil.getTempFile(jpegImageFile); verifyTempFile(tempFile); OutputStream os = null; try { TiffOutputSet outputSet = null; // note that metadata might be null if no metadata is found. final IImageMetadata imageMetadata = Imaging.getMetadata(jpegImageFile); final JpegImageMetadata jpegMetadata = (JpegImageMetadata) imageMetadata; if (jpegMetadata != null) { // note that exif might be null if no Exif metadata is found. final TiffImageMetadata exif = jpegMetadata.getExif(); if (exif != null) { outputSet = exif.getOutputSet(); } } if (outputSet == null) { outputSet = new TiffOutputSet(); } final TiffOutputDirectory rootDirectory = outputSet.getOrCreateRootDirectory(); final TiffOutputDirectory exifDirectory = outputSet.getOrCreateExifDirectory(); if (metadata.getTitle() != null) { rootDirectory.removeField(MicrosoftTagConstants.EXIF_TAG_XPTITLE); rootDirectory.add(MicrosoftTagConstants.EXIF_TAG_XPTITLE, metadata.getTitle()); rootDirectory.removeField(TiffTagConstants.TIFF_TAG_IMAGE_DESCRIPTION); rootDirectory.add(TiffTagConstants.TIFF_TAG_IMAGE_DESCRIPTION, metadata.getTitle()); } if (metadata.getComment() != null) { rootDirectory.removeField(MicrosoftTagConstants.EXIF_TAG_XPCOMMENT); rootDirectory.add(MicrosoftTagConstants.EXIF_TAG_XPCOMMENT, metadata.getComment()); exifDirectory.removeField(ExifTagConstants.EXIF_TAG_USER_COMMENT); exifDirectory.add(ExifTagConstants.EXIF_TAG_USER_COMMENT, metadata.getComment()); } if (metadata.getSubject() != null) { rootDirectory.removeField(MicrosoftTagConstants.EXIF_TAG_XPSUBJECT); rootDirectory.add(MicrosoftTagConstants.EXIF_TAG_XPSUBJECT, metadata.getSubject()); } if (metadata.getOrientation() != null) { rootDirectory.removeField(TiffTagConstants.TIFF_TAG_ORIENTATION); rootDirectory.add(TiffTagConstants.TIFF_TAG_ORIENTATION, metadata.getOrientation()); } try { os = new FileOutputStream(tempFile); os = new BufferedOutputStream(os); new ExifRewriter().updateExifMetadataLossless(jpegImageFile, os, outputSet); } catch (Exception e) { Log.w(Application.TAG, "Error storing EXIF data lossless - try lossy approach"); os = new FileOutputStream(tempFile); os = new BufferedOutputStream(os); new ExifRewriter().updateExifMetadataLossy(jpegImageFile, os, outputSet); } IoUtils.closeQuietly(true, os); if (!FileUtil.moveFile(tempFile, jpegImageFile)) { throw new IOException("Failed to rename file " + tempFile.getAbsolutePath() + " to " + jpegImageFileName); } } finally { IoUtils.closeQuietly(false, os); } } /** * Change the XMP metadata. * * @param jpegImageFileName the file for which metadata should be changed. * @param metadata the new metadata. * @throws ImageReadException thrown if the metadata cannot be read. * @throws ImageWriteException thrown if the metadata cannot be written. * @throws IOException thrown in case of other errors while reading metadata. * @throws XMPException thrown in case of issues with XML handling. */ @SuppressWarnings("resource") private static void changeXmpMetadata(@NonNull final String jpegImageFileName, @NonNull final JpegMetadata metadata) throws IOException, ImageReadException, ImageWriteException, XMPException { File jpegImageFile = new File(jpegImageFileName); File tempFile = FileUtil.getTempFile(jpegImageFile); verifyTempFile(tempFile); OutputStream os = null; try { final String xmpString = Imaging.getXmpXml(jpegImageFile); XmpHandler parser = new XmpHandler(xmpString); if (changeExifAllowed()) { // Change standard fields only if EXIF allowed parser.setDcTitle(metadata.getTitle()); parser.setDcDescription(metadata.getDescription()); parser.setDcSubject(metadata.getSubject()); parser.setUserComment(metadata.getComment()); parser.setMicrosoftPerson(metadata.getPerson()); } parser.setJeItem(XmpHandler.ITEM_TITLE, metadata.getTitle()); parser.setJeItem(XmpHandler.ITEM_DESCRIPTION, metadata.getDescription()); parser.setJeItem(XmpHandler.ITEM_SUBJECT, metadata.getSubject()); parser.setJeItem(XmpHandler.ITEM_COMMENT, metadata.getComment()); parser.setJeItem(XmpHandler.ITEM_PERSON, metadata.getPerson()); parser.setJeItem(XmpHandler.ITEM_X_CENTER, metadata.getXCenterString()); parser.setJeItem(XmpHandler.ITEM_Y_CENTER, metadata.getYCenterString()); parser.setJeItem(XmpHandler.ITEM_OVERLAY_SCALE_FACTOR, metadata.getOverlayScaleFactorString()); parser.setJeItem(XmpHandler.ITEM_X_POSITION, metadata.getXPositionString()); parser.setJeItem(XmpHandler.ITEM_Y_POSITION, metadata.getYPositionString()); parser.setJeItem(XmpHandler.ITEM_ZOOM_FACTOR, metadata.getZoomFactorString()); parser.setJeDate(XmpHandler.ITEM_ORGANIZE_DATE, metadata.getOrganizeDate()); parser.setJeItem(XmpHandler.ITEM_RIGHT_LEFT, metadata.getRightLeftString()); parser.setJeItem(XmpHandler.ITEM_BRIGHTNESS, metadata.getBrightnessString()); parser.setJeItem(XmpHandler.ITEM_CONTRAST, metadata.getContrastString()); parser.setJeItem(XmpHandler.ITEM_SATURATION, metadata.getSaturationString()); parser.setJeItem(XmpHandler.ITEM_COLOR_TEMPERATURE, metadata.getColorTemperatureString()); parser.setJeItem(XmpHandler.ITEM_OVERLAY_COLOR, metadata.getOverlayColorString()); parser.setJeItem(XmpHandler.ITEM_PUPIL_SIZE, metadata.getPupilSizeString()); parser.setJeItem(XmpHandler.ITEM_PUPIL_X_OFFSET, metadata.getPupilXOffsetString()); parser.setJeItem(XmpHandler.ITEM_PUPIL_Y_OFFSET, metadata.getPupilYOffsetString()); parser.setJeInt(XmpHandler.ITEM_FLAGS, metadata.getFlags()); os = new FileOutputStream(tempFile); os = new BufferedOutputStream(os); new JpegXmpRewriter().updateXmpXml(jpegImageFile, os, parser.getXmpString()); IoUtils.closeQuietly(true, os); if (!FileUtil.moveFile(tempFile, jpegImageFile)) { throw new IOException("Failed to rename file " + tempFile.getAbsolutePath() + " to " + jpegImageFileName); } } finally { IoUtils.closeQuietly(false, os); } } /** * Verify if the temporary file already exists. If yes, delete it. * * @param tempFile the temporary file. */ private static void verifyTempFile(@NonNull final File tempFile) { if (tempFile.exists()) { Log.w(Application.TAG, "tempFile " + tempFile.getName() + " already exists - deleting it"); boolean success = FileUtil.deleteFile(tempFile); if (!success) { Log.w(Application.TAG, "Failed to delete file" + tempFile.getName()); } } } /** * Check if the settings allow a change of the JPEG. * * @return true if it is allowed to change image files. */ private static boolean changeJpegAllowed() { int storeOption = PreferenceUtil.getSharedPreferenceIntString(R.string.key_store_option, R.string.pref_default_store_options); return storeOption > 0; } /** * Check if the settings allow a change of the EXIF data. * * @return true if it is allowed to change EXIF data. */ private static boolean changeExifAllowed() { int storeOption = PreferenceUtil.getSharedPreferenceIntString(R.string.key_store_option, R.string.pref_default_store_options); return storeOption == 2; } /** * Exception indicating that an error appeared while storing EXIF data. */ public static final class ExifStorageException extends IOException { /** * The serial version id. */ private static final long serialVersionUID = 1L; /** * Standard constructor, passing the causing exception. * * @param cause The exception. */ private ExifStorageException(final Throwable cause) { super(cause); } } }