ImageReader.java

/*
 * Copyright (C) 2016 Alberto Irurueta Carro (alberto@irurueta.com)
 *
 * 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.irurueta.server.commons.image;

import org.apache.commons.codec.binary.Base64;
import org.apache.commons.imaging.ImageFormats;
import org.apache.commons.imaging.ImageInfo;
import org.apache.commons.imaging.ImageReadException;
import org.apache.commons.imaging.Imaging;
import org.apache.commons.imaging.common.RationalNumber;
import org.apache.commons.imaging.formats.jpeg.JpegImageMetadata;
import org.apache.commons.imaging.formats.tiff.TiffField;
import org.apache.commons.imaging.formats.tiff.constants.ExifTagConstants;
import org.apache.commons.imaging.formats.tiff.constants.GpsTagConstants;
import org.apache.commons.imaging.formats.tiff.constants.TiffTagConstants;
import org.apache.commons.imaging.formats.tiff.fieldtypes.FieldTypeRational;
import org.apache.commons.imaging.formats.tiff.fieldtypes.FieldTypeShort;

import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.lang.ref.SoftReference;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.zip.CRC32;

/**
 * Class to read image metadata.
 */
public class ImageReader {
    /**
     * Constant indicating if CRC is computed by default.
     */
    public static final boolean DEFAULT_COMPUTE_CRC = true;

    /**
     * Constant indicating if MD5 hash is computed by default.
     */
    public static final boolean DEFAULT_COMPUTE_MD5 = true;

    /**
     * Buffer size to compute CRC and MD5.
     */
    public static final int BUFFER_SIZE = 1024;

    /**
     * Reference to singleton instance of this class.
     */
    private static SoftReference<ImageReader> mReference;

    /**
     * Indicates if CRC will be computed when reading image metadata.
     */
    private volatile boolean mComputeCrc = DEFAULT_COMPUTE_CRC;

    /**
     * Indicates if MD5 will be computed when reading image metadata.
     */
    private volatile boolean mComputeMd5 = DEFAULT_COMPUTE_MD5;

    /**
     * Constructor.
     */
    private ImageReader() {
    }

    /**
     * Factory method. Creates or returns singleton instance.
     *
     * @return singleton instance.
     */
    public static synchronized ImageReader getInstance() {
        ImageReader reader;
        if (mReference == null || (reader = mReference.get()) == null) {
            reader = new ImageReader();
            mReference = new SoftReference<>(reader);
        }
        return reader;
    }

    /**
     * Indicates if CRC computation is enabled.
     *
     * @return true if CRC computation is enabled, false otherwise.
     */
    public synchronized boolean isComputeCrcEnabled() {
        return mComputeCrc;
    }

    /**
     * Specifies whether CRC computation is enabled.
     *
     * @param computeCrc true if CRC computation must be enabled, false
     *                   otherwise.
     */
    public synchronized void setComputeCrcEnabled(final boolean computeCrc) {
        this.mComputeCrc = computeCrc;
    }

    /**
     * Indicates if MD5 hash computation is enabled.
     *
     * @return true if MD5 computation is enabled, false otherwise.
     */
    public synchronized boolean isComputeMd5Enabled() {
        return mComputeMd5;
    }

    /**
     * Specifies whether MD5 hash computation is enabled.
     *
     * @param computeMd5 true if MD5 computation must be enabled, false
     *                   otherwise.
     */
    public synchronized void setComputeMd5Enabled(final boolean computeMd5) {
        this.mComputeMd5 = computeMd5;
    }

    /**
     * Reads image metadata from provided image file.
     *
     * @param f file containing an image in one of the supported formats (jpg,
     *          png, gif or bmp).
     * @return result containing image metadata and image file information.
     * @throws InvalidImageException throws if file is corrupted, contains
     *                               invalid data, is not an image or format is not supported.
     * @throws IOException           if an I/O error occurs.
     */
    public ImageReaderResult readImage(final File f) throws InvalidImageException,
            IOException {
        try {
            final ImageReaderResult result = new ImageReaderResult();
            final ImageInfo imageInfo = Imaging.getImageInfo(f);
            result.setValid(internalCheckValid(imageInfo));
            result.setFileLength(f.length());
            result.setLastModified(f.lastModified());

            // Read metadata
            result.setContentType(imageInfo.getMimeType());
            result.setImageFormat(getImageFormat(imageInfo));

            final ImageMetadata metadata = new ImageMetadata();
            result.setMetadata(metadata);

            // if file is JPEG read its exif data
            if (imageInfo.getFormat() ==
                    ImageFormats.JPEG) {
                internalReadExif(f, imageInfo, metadata);
            } else {
                // if not, at least set image size so we can generate thumbnails
                metadata.setWidth(imageInfo.getWidth());
                metadata.setHeight(imageInfo.getHeight());
            }

            computeCRCAndMd5(f, result);
            return result;
        } catch (final ImageReadException e) {
            throw new InvalidImageException(e);
        }
    }

    /**
     * Check if valid is one of the supported image formats.
     *
     * @param f an image file.
     * @return true if file appears to be valid, false otherwise.
     * @throws InvalidImageException if image file is corrupted.
     * @throws IOException           if an I/O error occurs.
     */
    public static boolean checkValidFile(final File f) throws InvalidImageException,
            IOException {
        try {
            final ImageInfo imageInfo = Imaging.getImageInfo(f);
            return internalCheckValid(imageInfo);
        } catch (final IOException e) {
            throw e;
        } catch (Exception e) {
            throw new InvalidImageException(e);
        }
    }

    /**
     * Computes CRC and MD5 hashes for provided file and the results get stored
     * in provided result instance.
     *
     * @param f      file to compute CRC and MD5 hashes.
     * @param result instance where CRC and MD5 will be stored.
     * @throws IOException if an I/O error occurs.
     */
    private void computeCRCAndMd5(final File f, final ImageReaderResult result)
            throws IOException {
        if (f == null || result == null) {
            return;
        }

        try (final InputStream stream = new FileInputStream(f)) {
            CRC32 crc = null;
            if (mComputeCrc) {
                crc = new CRC32();
            }

            MessageDigest digest = null;
            if (mComputeMd5) {
                try {
                    digest = MessageDigest.getInstance("MD5");
                    digest.reset();
                } catch (final NoSuchAlgorithmException e) {
                    throw new IOException(e);
                }
            }


            final byte[] buffer = new byte[BUFFER_SIZE];
            int n;
            while ((n = stream.read(buffer)) > 0) {
                if (crc != null) {
                    crc.update(buffer, 0, n);
                }
                if (digest != null) {
                    digest.update(buffer, 0, n);
                }
            }

            if (crc != null) {
                result.setCrc(crc.getValue());
            }
            if (digest != null) {
                result.setMd5(Base64.encodeBase64String(digest.digest()));
            }
        }
    }

    /**
     * Determines image format for a given image file.
     *
     * @param imageInfo structure containing metadata of image file being read.
     * @return detected image format.
     */
    private com.irurueta.server.commons.image.ImageFormat getImageFormat(
            final ImageInfo imageInfo) {
        final org.apache.commons.imaging.ImageFormat format = imageInfo.getFormat();
        if (format == ImageFormats.JPEG) {
            return com.irurueta.server.commons.image.ImageFormat.JPEG;
        } else if (format == ImageFormats.PNG) {
            return com.irurueta.server.commons.image.ImageFormat.PNG;
        } else if (format == ImageFormats.GIF) {
            return com.irurueta.server.commons.image.ImageFormat.GIF;
        } else if (format == ImageFormats.BMP) {
            return com.irurueta.server.commons.image.ImageFormat.BMP;
        } else {
            return com.irurueta.server.commons.image.ImageFormat.UNKNOWN;
        }
    }

    /**
     * Indicates if image file corresponds to one of the supported image
     * formats.
     *
     * @param imageInfo structure containing metadata of image file being read.
     * @return if image file has one of the supported formats.
     */
    private static boolean internalCheckValid(final ImageInfo imageInfo) {
        final org.apache.commons.imaging.ImageFormat format = imageInfo.getFormat();
        return format == ImageFormats.JPEG ||
                format == ImageFormats.PNG ||
                format == ImageFormats.GIF ||
                format == ImageFormats.BMP;
    }

    /**
     * Internal method in charge of reading image metadata and EXIF tags.
     *
     * @param f         image file.
     * @param imageInfo structure containing metadata of image file being read.
     * @param result    structure where resulting image metadata will be stored.
     * @throws InvalidImageException if image file is corrupted or not
     *                               supported.
     * @throws IOException           if an I/O error occurs.
     */
    private void internalReadExif(final File f, final ImageInfo imageInfo,
                                  final ImageMetadata result) throws InvalidImageException, IOException {

        try {
            // store image size
            result.setWidth(imageInfo.getWidth());
            result.setHeight(imageInfo.getHeight());

            final JpegImageMetadata metadata =
                    (JpegImageMetadata) Imaging.getMetadata(f);
            if (metadata == null) {
                return;
            }

            // Get maker
            final TiffField makerField = metadata.findEXIFValue(
                    TiffTagConstants.TIFF_TAG_MAKE);
            String maker = null;
            if (makerField != null) {
                maker = makerField.getValueDescription();
            }
            if (maker != null) {
                result.setMaker(trim(maker));
            }

            // Get model
            final TiffField modelField = metadata.findEXIFValue(
                    TiffTagConstants.TIFF_TAG_MODEL);
            String model = null;
            if (modelField != null) {
                model = modelField.getValueDescription();
            }
            if (model != null) {
                result.setModel(trim(model));
            }

            // Focal length
            final TiffField focalLengthField = metadata.findEXIFValue(
                    ExifTagConstants.EXIF_TAG_FOCAL_LENGTH);
            if (focalLengthField != null &&
                    focalLengthField.getFieldType() instanceof FieldTypeRational) {
                final RationalNumber number =
                        (RationalNumber) focalLengthField.getValue();
                if (number != null) {
                    result.setFocalLength(number.doubleValue());
                }
            }

            // Focal plane x resolution
            final TiffField focalPlaneXResolutionField = metadata.findEXIFValue(
                    ExifTagConstants.EXIF_TAG_FOCAL_PLANE_XRESOLUTION_EXIF_IFD);
            if (focalPlaneXResolutionField != null &&
                    focalPlaneXResolutionField.getFieldType() instanceof FieldTypeRational) {
                final RationalNumber number =
                        (RationalNumber) focalPlaneXResolutionField.getValue();
                if (number != null) {
                    result.setFocalPlaneXResolution(number.doubleValue());
                }
            }

            // Focal plane y resolution
            final TiffField focalPlaneYResolutionField = metadata.findEXIFValue(
                    ExifTagConstants.EXIF_TAG_FOCAL_PLANE_YRESOLUTION_EXIF_IFD);
            if (focalPlaneYResolutionField != null &&
                    focalPlaneYResolutionField.getFieldType() instanceof FieldTypeRational) {
                final RationalNumber number =
                        (RationalNumber) focalPlaneYResolutionField.getValue();
                if (number != null) {
                    result.setFocalPlaneYResolution(number.doubleValue());
                }
            }

            // Focal plane resolution unit
            final TiffField focalPlaneResolutionUnitField = metadata.findEXIFValue(
                    ExifTagConstants.EXIF_TAG_FOCAL_PLANE_RESOLUTION_UNIT_EXIF_IFD);
            if (focalPlaneResolutionUnitField != null &&
                    focalPlaneResolutionUnitField.getFieldType() instanceof FieldTypeShort) {
                final Short number =
                        (Short) focalPlaneResolutionUnitField.getValue();
                if (number != null) {
                    result.setFocalPlaneResolutionUnit(Unit.fromValue(number));
                }
            }

            // Orientation
            final TiffField orientationField = metadata.findEXIFValue(
                    TiffTagConstants.TIFF_TAG_ORIENTATION);
            if (orientationField != null &&
                    orientationField.getFieldType() instanceof FieldTypeShort) {
                final Short number = (Short) orientationField.getValue();
                if (number != null) {
                    result.setOrientation(ImageOrientation.fromValue(number));
                }
            }

            // gps latitude
            Double latitude = null;
            final TiffField gpsLatitudeField = metadata.findEXIFValue(
                    GpsTagConstants.GPS_TAG_GPS_LATITUDE);
            if (gpsLatitudeField != null &&
                    gpsLatitudeField.getFieldType() instanceof FieldTypeRational) {
                final RationalNumber[] gpsLatitude =
                        (RationalNumber[]) gpsLatitudeField.getValue();

                if (gpsLatitude != null && gpsLatitude.length >= 3) {
                    final RationalNumber gpsLatitudeDegrees = gpsLatitude[0];
                    final RationalNumber gpsLatitudeMinutes = gpsLatitude[1];
                    final RationalNumber gpsLatitudeSeconds = gpsLatitude[2];

                    // obtain latitude ref
                    final TiffField gpsLatitudeRefField = metadata.findEXIFValue(
                            GpsTagConstants.GPS_TAG_GPS_LATITUDE_REF);
                    // set north by default
                    String gpsLatitudeRef = GpsTagConstants.GPS_TAG_GPS_LATITUDE_REF_VALUE_NORTH;
                    if (gpsLatitudeRefField != null) {
                        gpsLatitudeRef = (String) gpsLatitudeRefField.getValue();
                    }
                    double tmp = gpsLatitudeDegrees.doubleValue() +
                            gpsLatitudeMinutes.doubleValue() / 60.0 +
                            gpsLatitudeSeconds.doubleValue() / 3600.0;
                    if (!gpsLatitudeRef.toUpperCase().contains(
                            GpsTagConstants.GPS_TAG_GPS_LATITUDE_REF_VALUE_NORTH.
                                    toUpperCase())) {
                        tmp *= -1.0;
                    }
                    latitude = tmp;
                }
            }

            // gps longitude
            Double longitude = null;
            final TiffField gpsLongitudeField = metadata.findEXIFValue(
                    GpsTagConstants.GPS_TAG_GPS_LONGITUDE);
            if (gpsLongitudeField != null &&
                    gpsLongitudeField.getFieldType() instanceof FieldTypeRational) {
                final RationalNumber[] gpsLongitude = (RationalNumber[])
                        gpsLongitudeField.getValue();

                if (gpsLongitude != null && gpsLongitude.length >= 3) {
                    final RationalNumber gpsLongitudeDegrees = gpsLongitude[0];
                    final RationalNumber gpsLongitudeMinutes = gpsLongitude[1];
                    final RationalNumber gpsLongitudeSeconds = gpsLongitude[2];

                    // obtain longitude ref
                    final TiffField gpsLongitudeRefField = metadata.findEXIFValue(
                            GpsTagConstants.GPS_TAG_GPS_LONGITUDE_REF);
                    // set east by default
                    String gpsLongitudeRef = GpsTagConstants.GPS_TAG_GPS_LONGITUDE_REF_VALUE_EAST;
                    if (gpsLongitudeRefField != null) {
                        gpsLongitudeRef = (String) gpsLongitudeRefField.
                                getValue();
                    }
                    double tmp = gpsLongitudeDegrees.doubleValue() +
                            gpsLongitudeMinutes.doubleValue() / 60.0 +
                            gpsLongitudeSeconds.doubleValue() / 3600.0;
                    if (!gpsLongitudeRef.toUpperCase().contains(
                            GpsTagConstants.GPS_TAG_GPS_LONGITUDE_REF_VALUE_EAST.
                                    toUpperCase())) {
                        tmp *= -1.0;
                    }
                    longitude = tmp;
                }
            }

            // gps altitude
            Double altitude = null;
            final TiffField gpsAltitudeField = metadata.findEXIFValue(
                    GpsTagConstants.GPS_TAG_GPS_ALTITUDE);
            if (gpsAltitudeField != null &&
                    gpsAltitudeField.getFieldType() instanceof FieldTypeRational) {
                final RationalNumber number =
                        (RationalNumber) gpsAltitudeField.getValue();
                if (number != null) {
                    double tmp = number.doubleValue();

                    // check if above or under sea level
                    final TiffField gpsAltitudeRefField = metadata.findEXIFValue(
                            GpsTagConstants.GPS_TAG_GPS_ALTITUDE_REF);
                    Short above = GpsTagConstants.GPS_TAG_GPS_ALTITUDE_REF_VALUE_ABOVE_SEA_LEVEL;
                    if (gpsAltitudeRefField != null &&
                            gpsAltitudeRefField.getFieldType() instanceof FieldTypeShort) {
                        above = (Short) gpsAltitudeRefField.getValue();
                    }
                    if (above != GpsTagConstants.GPS_TAG_GPS_ALTITUDE_REF_VALUE_ABOVE_SEA_LEVEL) {
                        // below sea level
                        tmp *= -1.0;
                    }
                    altitude = tmp;
                }
            }

            GPSCoordinates coordinates = null;
            if (latitude != null && longitude != null && altitude != null) {
                coordinates = new GPSCoordinates(latitude, longitude, altitude);
            } else if (latitude != null && longitude != null) { // altitude == null
                coordinates = new GPSCoordinates(latitude, longitude);
            }

            if (coordinates != null) {
                result.setLocation(coordinates);
            }

            if (result.getOrientation() != null) {
                // if orientation is available as exif data, check if width and
                // height need to be exchanged
                switch (result.getOrientation()) {
                    case RIGHT_TOP:
                        // orientation == 6 (clockwise 270º)
                    case LEFT_BOTTOM:
                        // orientation == 8 (clockwise 90º)
                        // width and height must be exchanged
                        result.setWidth(imageInfo.getHeight());
                        result.setHeight(imageInfo.getWidth());
                        break;
                    default:
                        break;
                }
            }

            // Get artist
            final TiffField artistField = metadata.findEXIFValue(
                    TiffTagConstants.TIFF_TAG_ARTIST);
            String artist = null;
            if (artistField != null) {
                artist = artistField.getValueDescription();
            }
            if (artist != null) {
                result.setArtist(trim(artist));
            }

            // Get copyright
            final TiffField copyrightField = metadata.findEXIFValue(
                    TiffTagConstants.TIFF_TAG_COPYRIGHT);
            String copyright = null;
            if (copyrightField != null) {
                copyright = copyrightField.getValueDescription();
            }
            if (copyright != null) {
                result.setCopyright(trim(copyright));
            }

            // Get document name
            final TiffField documentNameField = metadata.findEXIFValue(
                    TiffTagConstants.TIFF_TAG_DOCUMENT_NAME);
            String documentName = null;
            if (documentNameField != null && artistField != null) {
                documentName = artistField.getValueDescription();
            }
            if (documentName != null) {
                result.setDocumentName(trim(documentName));
            }

            // Get host computer
            final TiffField hostComputerField = metadata.findEXIFValue(
                    TiffTagConstants.TIFF_TAG_HOST_COMPUTER);
            String hostComputer = null;
            if (hostComputerField != null) {
                hostComputer = hostComputerField.getValueDescription();
            }
            if (hostComputer != null) {
                result.setHostComputer(trim(hostComputer));
            }

            // Get image description
            final TiffField imageDescriptionField = metadata.findEXIFValue(
                    TiffTagConstants.TIFF_TAG_IMAGE_DESCRIPTION);
            String imageDescription = null;
            if (imageDescriptionField != null) {
                imageDescription = imageDescriptionField.getValueDescription();
            }
            if (imageDescription != null) {
                result.setImageDescription(trim(imageDescription));
            }

            // Get software
            final TiffField softwareField = metadata.findEXIFValue(
                    TiffTagConstants.TIFF_TAG_SOFTWARE);
            String software = null;
            if (softwareField != null) {
                software = softwareField.getValueDescription();
            }
            if (software != null) {
                result.setSoftware(trim(software));
            }

            // Get target printer
            final TiffField targetPrinterField = metadata.findEXIFValue(
                    TiffTagConstants.TIFF_TAG_TARGET_PRINTER);
            String targetPrinter = null;
            if (targetPrinterField != null) {
                targetPrinter = targetPrinterField.getValueDescription();
            }
            if (targetPrinter != null) {
                result.setTargetPrinter(trim(targetPrinter));
            }

            // Get camera serial number
            final TiffField cameraSerialNumberField = metadata.findEXIFValue(
                    ExifTagConstants.EXIF_TAG_BODY_SERIAL_NUMBER);
            String cameraSerialNumber = null;
            if (cameraSerialNumberField != null) {
                cameraSerialNumber =
                        cameraSerialNumberField.getValueDescription();
            }
            if (cameraSerialNumber != null) {
                result.setCameraSerialNumber(trim(cameraSerialNumber));
            }

            // TODO: get lens serial number

            // Get digital zoom ratio
            final TiffField digitalZoomRatioField = metadata.findEXIFValue(
                    ExifTagConstants.EXIF_TAG_DIGITAL_ZOOM_RATIO);
            if (digitalZoomRatioField != null &&
                    digitalZoomRatioField.getFieldType() instanceof FieldTypeRational) {
                final RationalNumber number =
                        (RationalNumber) digitalZoomRatioField.getValue();
                if (number != null) {
                    result.setDigitalZoomRatio(number.doubleValue());
                }
            }

            // Get exposure time
            final TiffField exposureTimeField = metadata.findEXIFValue(
                    ExifTagConstants.EXIF_TAG_EXPOSURE_TIME);
            if (exposureTimeField != null &&
                    exposureTimeField.getFieldType() instanceof FieldTypeRational) {
                final RationalNumber number =
                        (RationalNumber) exposureTimeField.getValue();
                if (number != null) {
                    result.setExposureTime(number.doubleValue());
                }
            }

            // Get flash
            final TiffField flashField = metadata.findEXIFValue(
                    ExifTagConstants.EXIF_TAG_FLASH);
            if (flashField != null &&
                    flashField.getFieldType() instanceof FieldTypeShort) {
                final Short number = (Short) flashField.getValue();
                if (number != null) {
                    result.setFlash(Flash.fromValue(number));
                }
            }

            // Get flash energy
            final TiffField flashEnergyField = metadata.findEXIFValue(
                    ExifTagConstants.EXIF_TAG_FLASH_ENERGY_EXIF_IFD);
            if (flashEnergyField != null &&
                    flashEnergyField.getFieldType() instanceof FieldTypeRational) {
                final RationalNumber number =
                        (RationalNumber) flashEnergyField.getValue();
                if (number != null) {
                    result.setFlashEnergy(number.doubleValue());
                }
            }

            // Get F number
            final TiffField fNumberField = metadata.findEXIFValue(
                    ExifTagConstants.EXIF_TAG_FNUMBER);
            if (fNumberField != null &&
                    fNumberField.getFieldType() instanceof FieldTypeRational) {
                final RationalNumber number = (RationalNumber) fNumberField.getValue();
                if (number != null) {
                    result.setFNumber(number.doubleValue());
                }
            }

            // Get focal length in 35mm film
            final TiffField focalLength35mmFormatField = metadata.findEXIFValue(
                    ExifTagConstants.EXIF_TAG_FOCAL_LENGTH_IN_35MM_FORMAT);
            if (focalLength35mmFormatField != null &&
                    focalLength35mmFormatField.getFieldType() instanceof FieldTypeRational) {
                final RationalNumber number =
                        (RationalNumber) focalLength35mmFormatField.getValue();
                if (number != null) {
                    result.setFocalLengthIn35mmFilm(number.doubleValue());
                }
            }

            // Get unique camera model
            final TiffField uniqueCameraModelField = metadata.findEXIFValue(
                    ExifTagConstants.EXIF_TAG_MODEL_2);
            String uniqueCameraModel = null;
            if (uniqueCameraModelField != null && modelField != null) {
                uniqueCameraModel = modelField.getValueDescription();
            }
            if (uniqueCameraModel != null) {
                result.setUniqueCameraModel(trim(uniqueCameraModel));
            }

            // Get subject distance
            final TiffField subjectDistanceField = metadata.findEXIFValue(
                    ExifTagConstants.EXIF_TAG_SUBJECT_DISTANCE);
            if (subjectDistanceField != null &&
                    subjectDistanceField.getFieldType() instanceof FieldTypeRational) {
                final RationalNumber number =
                        (RationalNumber) subjectDistanceField.getValue();
                if (number != null) {
                    result.setSubjectDistance(number.doubleValue());
                }
            }

            // Get shutter speed value
            final TiffField shutterSpeedField = metadata.findEXIFValue(
                    ExifTagConstants.EXIF_TAG_SHUTTER_SPEED_VALUE);
            if (shutterSpeedField != null &&
                    shutterSpeedField.getFieldType() instanceof FieldTypeRational) {
                final RationalNumber number =
                        (RationalNumber) shutterSpeedField.getValue();
                if (number != null) {
                    result.setShutterSpeedValue(number.doubleValue());
                }
            }

            // Get ISO
            final TiffField isoField = metadata.findEXIFValue(
                    ExifTagConstants.EXIF_TAG_ISO);
            if (isoField != null &&
                    isoField.getFieldType() instanceof FieldTypeShort) {
                final Short number = (Short) isoField.getValue();
                if (number != null) {
                    result.setISO(number.intValue());
                }
            }

        } catch (final ImageReadException e) {
            throw new InvalidImageException(e);
        }
    }

    /**
     * Trims leading and ending apostrophes from provided string.
     *
     * @param s string to be processed.
     * @return trimmed string.
     */
    private String trim(final String s) {
        String result = null;
        if (s != null) {
            result = s;
            if (s.startsWith("'")) {
                result = result.substring(1);
            }
            if (s.endsWith("'")) {
                result = result.substring(0, result.length() - 1);
            }
        }
        return result;
    }
}