From abd5b8ebc5b8d536eb45213fc1f96595f7093765 Mon Sep 17 00:00:00 2001 From: tmp-hallenser <1869257+tmp-hallenser@users.noreply.github.com> Date: Wed, 30 Oct 2019 09:40:55 +0100 Subject: [PATCH 01/11] Added extraction of many additional fields and video support * Added extraction for many additional fields * Refactored in parts internal calculation of GPS location data * Added support for video (FFMpeg for native) --- lib/PHPExif/Adapter/Native.php | 208 +++++++++++++++++-- lib/PHPExif/Exif.php | 355 +++++++++++++++++++++++++++++++- lib/PHPExif/Mapper/Exiftool.php | 125 +++++++++-- lib/PHPExif/Mapper/Native.php | 118 +++++++---- 4 files changed, 723 insertions(+), 83 deletions(-) diff --git a/lib/PHPExif/Adapter/Native.php b/lib/PHPExif/Adapter/Native.php index 1bf3185..cf5d117 100644 --- a/lib/PHPExif/Adapter/Native.php +++ b/lib/PHPExif/Adapter/Native.php @@ -12,6 +12,7 @@ namespace PHPExif\Adapter; use PHPExif\Exif; +use FFMpeg; /** * PHP Exif Native Reader Adapter @@ -173,23 +174,40 @@ public function getSectionsAsArrays() */ public function getExifFromFile($file) { - $sections = $this->getRequiredSections(); - $sections = implode(',', $sections); - $sections = (empty($sections)) ? null : $sections; - - $data = @exif_read_data( - $file, - $sections, - $this->getSectionsAsArrays(), - $this->getIncludeThumbnail() - ); - - if (false === $data) { - return false; - } + $mimeType = mime_content_type($file); + + if (strpos($mimeType, 'video') !== 0) { + + // Photo + $sections = $this->getRequiredSections(); + $sections = implode(',', $sections); + $sections = (empty($sections)) ? null : $sections; + + $data = @exif_read_data( + $file, + $sections, + $this->getSectionsAsArrays(), + $this->getIncludeThumbnail() + ); + + if (false === $data) { + return false; + } + + $xmpData = $this->getIptcData($file); + $data = array_merge($data, array(self::SECTION_IPTC => $xmpData)); - $xmpData = $this->getIptcData($file); - $data = array_merge($data, array(self::SECTION_IPTC => $xmpData)); + } else { + // Video + try { + + $data = $this->getVideoData($file); + $data['MimeType'] = $mimeType; + + } catch (Exception $exception) { + Logs::error(__METHOD__, __LINE__, $exception->getMessage()); + } + } // map the data: $mapper = $this->getMapper(); @@ -204,6 +222,164 @@ public function getExifFromFile($file) return $exif; } + /** + * Returns an array of video data + * + * @param string $file The file to read the video data from + * @return array + */ + public function getVideoData($filename) + { + + $metadata['FileSize'] = filesize($filename); + + $path_ffmpeg = exec('which ffmpeg'); + $path_ffprobe = exec('which ffprobe'); + $ffprobe = FFMpeg\FFProbe::create(array( + 'ffmpeg.binaries' => $path_ffmpeg, + 'ffprobe.binaries' => $path_ffprobe, + )); + + $stream = $ffprobe->streams($filename)->videos()->first()->all(); + $format = $ffprobe->format($filename)->all(); + if (isset($stream['width'])) { + $metadata['Width'] = $stream['width']; + } + if (isset($stream['height'])) { + $metadata['Height'] = $stream['height']; + } + if (isset($stream['tags']) && isset($stream['tags']['rotate']) && ($stream['tags']['rotate'] === '90' || $stream['tags']['rotate'] === '270')) { + $tmp = $metadata['Width']; + $metadata['Width'] = $metadata['Height']; + $metadata['Height'] = $tmp; + } + if (isset($stream['avg_frame_rate'])) { + $framerate = explode('/', $stream['avg_frame_rate']); + if (count($framerate) == 1) { + $framerate = $framerate[0]; + } elseif (count($framerate) == 2 && $framerate[1] != 0) { + $framerate = number_format($framerate[0] / $framerate[1], 3); + } else { + $framerate = ''; + } + if ($framerate !== '') { + $metadata['framerate'] = $framerate; + } + } + if (isset($format['duration'])) { + $metadata['duration'] = number_format($format['duration'], 3); + } + if (isset($format['tags'])) { + if (isset($format['tags']['creation_time']) && strtotime($format['tags']['creation_time']) !== 0) { + $metadata['DateTimeOriginal'] = date('Y-m-d H:i:s', strtotime($format['tags']['creation_time'])); + } + if (isset($format['tags']['location'])) { + $matches = []; + preg_match('/^([+-][0-9\.]+)([+-][0-9\.]+)\/$/', $format['tags']['location'], $matches); + if (count($matches) == 3 && + !preg_match('/^\+0+\.0+$/', $matches[1]) && + !preg_match('/^\+0+\.0+$/', $matches[2])) { + $metadata['GPSLatitude'] = $matches[1]; + $metadata['GPSLongitude'] = $matches[2]; + } + } + // QuickTime File Format defines several additional metadata + // Source: https://developer.apple.com/library/archive/documentation/QuickTime/QTFF/Metadata/Metadata.html + // Special case: iPhones write into tags->creation_time the creation time of the file + // -> When converting the video from HEVC (iOS Video format) to MOV, the creation_time + // is the time when the mov file was created, not when the video was shot (fixed in iOS12) + // (see e.g. https://michaelkummer.com/tech/apple/photos-videos-wrong-date/ (for the symptom) + // Solution: Use com.apple.quicktime.creationdate which is the true creation date of the video + if (isset($format['tags']['com.apple.quicktime.creationdate'])) { + $metadata['DateTimeOriginal'] = date('Y-m-d H:i:s', strtotime($format['tags']['com.apple.quicktime.creationdate'])); + } + if (isset($format['tags']['com.apple.quicktime.description'])) { + $metadata['description'] = $format['tags']['com.apple.quicktime.description']; + } + if (isset($format['tags']['com.apple.quicktime.title'])) { + $metadata['title'] = $format['tags']['com.apple.quicktime.title']; + } + if (isset($format['tags']['com.apple.quicktime.keywords'])) { + $metadata['keywords'] = $format['tags']['com.apple.quicktime.keywords']; + } + if (isset($format['tags']['com.apple.quicktime.location.ISO6709'])) { + $location_data = $this->readISO6709($format['tags']['com.apple.quicktime.location.ISO6709']); + $metadata['GPSLatitude'] = $location_data['latitude']; + $metadata['GPSLongitude'] = $location_data['longitude']; + $metadata['GPSAltitude'] = $location_data['altitude']; + } + // Not documented, but available on iPhone videos + if (isset($format['tags']['com.apple.quicktime.make'])) { + $metadata['Make'] = $format['tags']['com.apple.quicktime.make']; + } + // Not documented, but available on iPhone videos + if (isset($format['tags']['com.apple.quicktime.model'])) { + $metadata['Model'] = $format['tags']['com.apple.quicktime.model']; + } + } + + return $metadata; + } + + /** + * Converts results of ISO6709 parsing + * to decimal format for latitude and longitude + * See https://github.com/seanson/python-iso6709.git. + * + * @param string sign + * @param string degrees + * @param string minutes + * @param string seconds + * @param string fraction + * + * @return float + */ + private function convertDMStoDecimal(string $sign, string $degrees, string $minutes, string $seconds, string $fraction): float + { + if ($fraction !== '') { + if ($seconds !== '') { + $seconds = $seconds . $fraction; + } elseif ($minutes !== '') { + $minutes = $minutes . $fraction; + } else { + $degrees = $degrees . $fraction; + } + } + $decimal = floatval($degrees) + floatval($minutes) / 60.0 + floatval($seconds) / 3600.0; + if ($sign == '-') { + $decimal = -1.0 * $decimal; + } + return $decimal; + } + + /** + * Returns the latitude, longitude and altitude + * of a GPS coordiante formattet with ISO6709 + * See https://github.com/seanson/python-iso6709.git. + * + * @param string val_ISO6709 + * + * @return array + */ + private function readISO6709(string $val_ISO6709): array + { + $return = [ + 'latitude' => null, + 'longitude' => null, + 'altitude' => null, + ]; + $matches = []; + // Adjustment compared to https://github.com/seanson/python-iso6709.git + // Altitude have format +XX.XXXX -> Adjustment for decimal + preg_match('/^(?\+|-)(?[0,1]?\d{2})(?\d{2}?)?(?\d{2}?)?(?\.\d+)?(?\+|-)(?[0,1]?\d{2})(?\d{2}?)?(?\d{2}?)?(?\.\d+)?(?[\+\-][0-9]\d*(\.\d+)?)?\/$/', $val_ISO6709, $matches); + $return['latitude'] = $this->convertDMStoDecimal($matches['lat_sign'], $matches['lat_degrees'], $matches['lat_minutes'], $matches['lat_seconds'], $matches['lat_fraction']); + $return['longitude'] = $this->convertDMStoDecimal($matches['lng_sign'], $matches['lng_degrees'], $matches['lng_minutes'], $matches['lng_seconds'], $matches['lng_fraction']); + if (isset($matches['alt'])) { + $return['altitude'] = doubleval($matches['alt']); + } + return $return; + } + /** * Returns an array of IPTC data * diff --git a/lib/PHPExif/Exif.php b/lib/PHPExif/Exif.php index e6614e4..92296da 100755 --- a/lib/PHPExif/Exif.php +++ b/lib/PHPExif/Exif.php @@ -32,6 +32,7 @@ class Exif const CREDIT = 'credit'; const EXPOSURE = 'exposure'; const FILESIZE = 'FileSize'; + const FILENAME = 'FileName'; const FOCAL_LENGTH = 'focalLength'; const FOCAL_DISTANCE = 'focalDistance'; const HEADLINE = 'headline'; @@ -48,6 +49,17 @@ class Exif const VERTICAL_RESOLUTION = 'verticalResolution'; const WIDTH = 'width'; const GPS = 'gps'; + const ALTITUDE = 'altitude'; + const DESCRIPTION = 'description'; + const MAKE = 'make'; + const LONGITUDE = 'longitude'; + const LATITUDE = 'latitude'; + const IMGDIRECTION = 'imgDirection'; + const LENS = 'lens'; + const SUBJECT = 'subject'; + const CONTENTIDENTIFIER = 'contentIdentifier'; + const FRAMERATE = 'framerate'; + const DURATION = 'duration'; /** * The mapped EXIF data @@ -389,6 +401,7 @@ public function setFocusDistance($value) */ public function getWidth() { + if (!isset($this->data[self::WIDTH])) { return false; } @@ -705,7 +718,7 @@ public function setCreationDate(\DateTime $value) return $this; } - + /** * Returns the colorspace, if it exists * @@ -716,7 +729,7 @@ public function getColorSpace() if (!isset($this->data[self::COLORSPACE])) { return false; } - + return $this->data[self::COLORSPACE]; } @@ -732,7 +745,7 @@ public function setColorSpace($value) return $this; } - + /** * Returns the mimetype, if it exists * @@ -743,7 +756,7 @@ public function getMimeType() if (!isset($this->data[self::MIMETYPE])) { return false; } - + return $this->data[self::MIMETYPE]; } @@ -759,10 +772,10 @@ public function setMimeType($value) return $this; } - + /** * Returns the filesize, if it exists - * + * * @return int|boolean */ public function getFileSize() @@ -770,7 +783,7 @@ public function getFileSize() if (!isset($this->data[self::FILESIZE])) { return false; } - + return $this->data[self::FILESIZE]; } @@ -787,6 +800,33 @@ public function setFileSize($value) return $this; } + /** + * Returns the filename, if it exists + * + * @return string|boolean + */ + public function getFileName() + { + if (!isset($this->data[self::FILENAME])) { + return false; + } + + return $this->data[self::FILENAME]; + } + + /** + * Sets the filename + * + * @param string $value + * @return \PHPExif\Exif + */ + public function setFileName($value) + { + $this->data[self::FILENAME] = $value; + + return $this; + } + /** * Returns the orientation, if it exists * @@ -840,4 +880,305 @@ public function setGPS($value) return $this; } + + /** + * Sets the description value + * + * @param string $value + * @return \PHPExif\Exif + */ + public function setDescription($value) + { + $this->data[self::DESCRIPTION] = $value; + + return $this; + } + + /** + * Returns description, if it exists + * + * @return string|boolean + */ + public function getDescription() + { + if (!isset($this->data[self::DESCRIPTION])) { + return false; + } + + return $this->data[self::DESCRIPTION]; + } + + + /** + * Sets the Make value + * + * @param string $value + * @return \PHPExif\Exif + */ + public function setMake($value) + { + $this->data[self::MAKE] = $value; + + return $this; + } + + /** + * Returns make, if it exists + * + * @return string|boolean + */ + public function getMake() + { + if (!isset($this->data[self::MAKE])) { + return false; + } + + return $this->data[self::MAKE]; + } + + /** + * Sets the altitude value + * + * @param string $value + * @return \PHPExif\Exif + */ + public function setAltitude($value) + { + $this->data[self::ALTITUDE] = $value; + + return $this; + } + + /** + * Returns altitude, if it exists + * + * @return float|boolean + */ + public function getAltitude() + { + if (!isset($this->data[self::ALTITUDE])) { + return false; + } + + return $this->data[self::ALTITUDE]; + } + + /** + * Sets the altitude value + * + * @param string $value + * @return \PHPExif\Exif + */ + public function setLongitude($value) + { + $this->data[self::LONGITUDE] = $value; + + return $this; + } + + /** + * Returns altitude, if it exists + * + * @return float|boolean + */ + public function getLongitude() + { + if (!isset($this->data[self::LONGITUDE])) { + return false; + } + + return $this->data[self::LONGITUDE]; + } + + /** + * Sets the latitude value + * + * @param string $value + * @return \PHPExif\Exif + */ + public function setLatitude($value) + { + $this->data[self::LATITUDE] = $value; + + return $this; + } + + /** + * Returns latitude, if it exists + * + * @return float|boolean + */ + public function getLatitude() + { + if (!isset($this->data[self::LATITUDE])) { + return false; + } + + return $this->data[self::LATITUDE]; + } + + /** + * Sets the imgDirection value + * + * @param string $value + * @return \PHPExif\Exif + */ + public function setImgDirection($value) + { + $this->data[self::IMGDIRECTION] = $value; + + return $this; + } + + /** + * Returns imgDirection, if it exists + * + * @return float|boolean + */ + public function getImgDirection() + { + if (!isset($this->data[self::IMGDIRECTION])) { + return false; + } + + return $this->data[self::IMGDIRECTION]; + } + + + /** + * Sets the Make value + * + * @param string $value + * @return \PHPExif\Exif + */ + public function setLens($value) + { + $this->data[self::LENS] = $value; + + return $this; + } + + /** + * Returns make, if it exists + * + * @return string|boolean + */ + public function getLens() + { + if (!isset($this->data[self::LENS])) { + return false; + } + + return $this->data[self::LENS]; + } + + /** + * Sets the subject value + * + * @param string $value + * @return \PHPExif\Exif + */ + public function setSubject($value) + { + $this->data[self::SUBJECT] = $value; + + return $this; + } + + /** + * Returns subject, if it exists + * + * @return string|boolean + */ + public function getSubject() + { + if (!isset($this->data[self::SUBJECT])) { + return false; + } + + return $this->data[self::SUBJECT]; + } + + /** + * Sets the content identifier value + * + * @param string $value + * @return \PHPExif\Exif + */ + public function setContentIdentifier($value) + { + $this->data[self::CONTENTIDENTIFIER] = $value; + + return $this; + } + + /** + * Returns content identifier, if it exists + * + * @return string|boolean + */ + public function getContentIdentifier() + { + if (!isset($this->data[self::CONTENTIDENTIFIER])) { + return false; + } + + return $this->data[self::CONTENTIDENTIFIER]; + } + + + /** + * Sets the framerate value + * + * @param string $value + * @return \PHPExif\Exif + */ + public function setFramerate($value) + { + $this->data[self::FRAMERATE] = $value; + + return $this; + } + + /** + * Returns content identifier, if it exists + * + * @return string|boolean + */ + public function getFramerate() + { + if (!isset($this->data[self::FRAMERATE])) { + return false; + } + + return $this->data[self::FRAMERATE]; + } + + + /** + * Sets the duration value + * + * @param string $value + * @return \PHPExif\Exif + */ + public function setDuration($value) + { + $this->data[self::DURATION] = $value; + + return $this; + } + + /** + * Returns duration, if it exists + * + * @return string|boolean + */ + public function getDuration() + { + if (!isset($this->data[self::DURATION])) { + return false; + } + + return $this->data[self::DURATION]; + } } diff --git a/lib/PHPExif/Mapper/Exiftool.php b/lib/PHPExif/Mapper/Exiftool.php index b272f0a..90a0de9 100644 --- a/lib/PHPExif/Mapper/Exiftool.php +++ b/lib/PHPExif/Mapper/Exiftool.php @@ -35,6 +35,7 @@ class Exiftool implements MapperInterface const CREDIT = 'IPTC:Credit'; const EXPOSURETIME = 'ExifIFD:ExposureTime'; const FILESIZE = 'System:FileSize'; + const FILENAME = 'System:FileName'; const FOCALLENGTH = 'ExifIFD:FocalLength'; const HEADLINE = 'IPTC:Headline'; const IMAGEHEIGHT = 'File:ImageHeight'; @@ -52,6 +53,29 @@ class Exiftool implements MapperInterface const YRESOLUTION = 'IFD0:YResolution'; const GPSLATITUDE = 'GPS:GPSLatitude'; const GPSLONGITUDE = 'GPS:GPSLongitude'; + const GPSALTITUDE = 'GPS:GPSAltitude'; + const IMGDIRECTION = 'GPS:GPSImgDirection'; + const DESCRIPTION = 'IFD0:ImageDescription '; + const MAKE = 'IFD0:Make'; + const LENS = 'ExifIFD:LensModel'; + const SUBJECT = 'XMP-dc:Subject'; + const CONTENTIDENTIFIER = 'Apple:ContentIdentifier'; + + const DATETIMEORIGINAL_QUICKTIME = 'QuickTime:CreationDate'; + const IMAGEHEIGHT_VIDEO = 'Composite:ImageSize'; + const IMAGEWIDTH_VIDEO = 'Composite:ImageSize'; + const MAKE_QUICKTIME = 'QuickTime:Make'; + const MODEL_QUICKTIME = 'QuickTime:Model'; + const CONTENTIDENTIFIER_QUICKTIME = 'QuickTime:ContentIdentifier'; + const GPSLATITUDE_QUICKTIME = 'Composite:GPSLatitude'; + const GPSLONGITUDE_QUICKTIME = 'Composite:GPSLongitude'; + const GPSALTITUDE_QUICKTIME = 'Composite:GPSAltitude'; + const FRAMERATE = 'MPEG:FrameRate'; + const FRAMERATE_QUICKTIME_1 = 'Track1:VideoFrameRate'; + const FRAMERATE_QUICKTIME_2 = 'Track2:VideoFrameRate'; + const FRAMERATE_QUICKTIME_3 = 'Track3:VideoFrameRate'; + const DURATION = 'Composite:Duration'; + const DURATION_QUICKTIME = 'QuickTime:Duration'; /** * Maps the ExifTool fields to the fields of @@ -70,6 +94,7 @@ class Exiftool implements MapperInterface self::CREDIT => Exif::CREDIT, self::EXPOSURETIME => Exif::EXPOSURE, self::FILESIZE => Exif::FILESIZE, + self::FILENAME => Exif::FILENAME, self::FOCALLENGTH => Exif::FOCAL_LENGTH, self::APPROXIMATEFOCUSDISTANCE => Exif::FOCAL_DISTANCE, self::HEADLINE => Exif::HEADLINE, @@ -86,8 +111,30 @@ class Exiftool implements MapperInterface self::YRESOLUTION => Exif::VERTICAL_RESOLUTION, self::IMAGEWIDTH => Exif::WIDTH, self::CAPTIONABSTRACT => Exif::CAPTION, - self::GPSLATITUDE => Exif::GPS, - self::GPSLONGITUDE => Exif::GPS, + self::GPSLATITUDE => Exif::LATITUDE, + self::GPSLONGITUDE => Exif::LONGITUDE, + self::GPSALTITUDE => Exif::ALTITUDE, + self::MAKE => Exif::MAKE, + self::IMGDIRECTION => Exif::IMGDIRECTION, + self::LENS => Exif::LENS, + self::DESCRIPTION => Exif::DESCRIPTION, + self::SUBJECT => Exif::SUBJECT, + self::CONTENTIDENTIFIER => Exif::CONTENTIDENTIFIER, + self::DATETIMEORIGINAL_QUICKTIME => Exif::CREATION_DATE, + self::MAKE_QUICKTIME => Exif::MAKE, + self::MODEL_QUICKTIME => Exif::CAMERA, + self::CONTENTIDENTIFIER_QUICKTIME => Exif::CONTENTIDENTIFIER, + self::GPSLATITUDE_QUICKTIME => Exif::LATITUDE, + self::GPSLONGITUDE_QUICKTIME => Exif::LONGITUDE, + self::GPSALTITUDE_QUICKTIME => Exif::ALTITUDE, + self::IMAGEHEIGHT_VIDEO => Exif::HEIGHT, + self::IMAGEWIDTH_VIDEO => Exif::WIDTH, + self::FRAMERATE => Exif::FRAMERATE, + self::FRAMERATE_QUICKTIME_1 => Exif::FRAMERATE, + self::FRAMERATE_QUICKTIME_2 => Exif::FRAMERATE, + self::FRAMERATE_QUICKTIME_3 => Exif::FRAMERATE, + self::DURATION => Exif::DURATION, + self::DURATION_QUICKTIME => Exif::DURATION, ); /** @@ -136,8 +183,9 @@ public function mapRawData(array $data) $value = sprintf('%1$sm', $value); break; case self::DATETIMEORIGINAL: + case self::DATETIMEORIGINAL_QUICKTIME: try { - $value = new DateTime($value); + $value = new DateTime(date('Y-m-d H:i:s', strtotime($value))); } catch (\Exception $exception) { continue 2; } @@ -158,30 +206,69 @@ public function mapRawData(array $data) $value = reset($focalLengthParts); } break; + case self::GPSLATITUDE_QUICKTIME: + $value = $this->extractGPSCoordinates($value); + break; case self::GPSLATITUDE: - $gpsData['lat'] = $this->extractGPSCoordinates($value); + $latitudeRef = empty($data['GPS:GPSLatitudeRef']) ? 'N' : $data['GPS:GPSLatitudeRef']; + $value = (strtoupper($latitudeRef) === 'S' ? -1.0 : 1.0)*$this->extractGPSCoordinates($value); + break; + case self::GPSLONGITUDE_QUICKTIME: + $value = $this->extractGPSCoordinates($value); break; case self::GPSLONGITUDE: - $gpsData['lon'] = $this->extractGPSCoordinates($value); + $longitudeRef = empty($data['GPS:GPSLongitudeRef']) ? 'E' : $data['GPS:GPSLongitudeRef']; + $value = (strtoupper($longitudeRef) === 'W' ? -1 : 1) * $this->extractGPSCoordinates($value); + break; + case self::GPSALTITUDE: + $flip = 1; + if(!(empty($data['GPS:GPSAltitudeRef']))) { + $flip = ($data['GPS:GPSAltitudeRef'] == '1') ? -1 : 1; + } + $value = $flip * (float) $value; + break; + case self::GPSALTITUDE_QUICKTIME: + $flip = 1; + if(!(empty($data['Composite:GPSAltitudeRef']))) { + $flip = ($data['Composite:GPSAltitudeRef'] == '1') ? -1 : 1; + } + $value = $flip * (float) $value; + break; + case self::IMAGEHEIGHT_VIDEO: + case self::IMAGEWIDTH_VIDEO: + $value_splitted = explode("x", $value); + if(empty($mappedData[Exif::WIDTH])) { + if(!(empty($data['Composite:Rotation']))) { + if ($data['Composite:Rotation']=='90' || $data['Composite:Rotation']=='270') { + $mappedData[Exif::WIDTH] = intval($value_splitted[1]); + } else { + $mappedData[Exif::WIDTH] = intval($value_splitted[0]); + } + } else { + $mappedData[Exif::WIDTH] = intval($value_splitted[0]); + } + } + if(empty($mappedData[Exif::HEIGHT])) { + if(!(empty($data['Composite:Rotation']))) { + if ($data['Composite:Rotation']=='90' || $data['Composite:Rotation']=='270') { + $mappedData[Exif::HEIGHT] = intval($value_splitted[0]); + } else { + $mappedData[Exif::HEIGHT] = intval($value_splitted[1]); + } + } else { + $mappedData[Exif::HEIGHT] = intval($value_splitted[1]); + } + } + continue 2; break; } - // set end result $mappedData[$key] = $value; } // add GPS coordinates, if available - if (count($gpsData) === 2 && $gpsData['lat'] !== false && $gpsData['lon'] !== false) { - $latitudeRef = empty($data['GPS:GPSLatitudeRef'][0]) ? 'N' : $data['GPS:GPSLatitudeRef'][0]; - $longitudeRef = empty($data['GPS:GPSLongitudeRef'][0]) ? 'E' : $data['GPS:GPSLongitudeRef'][0]; - - $gpsLocation = sprintf( - '%s,%s', - (strtoupper($latitudeRef) === 'S' ? -1 : 1) * $gpsData['lat'], - (strtoupper($longitudeRef) === 'W' ? -1 : 1) * $gpsData['lon'] - ); - - $mappedData[Exif::GPS] = $gpsLocation; + if (!(empty($mappedData[Exif::LATITUDE])) && !(empty($mappedData[Exif::LONGITUDE]))) { + $mappedData[Exif::GPS] = sprintf('%s,%s', $mappedData[Exif::LATITUDE], $mappedData[Exif::LONGITUDE]); } else { unset($mappedData[Exif::GPS]); } @@ -197,8 +284,8 @@ public function mapRawData(array $data) */ protected function extractGPSCoordinates($coordinates) { - if ($this->numeric === true) { - return abs((float) $coordinates); + if (is_numeric($coordinates) === true) { + return ((float) $coordinates); } else { if (!preg_match('!^([0-9.]+) deg ([0-9.]+)\' ([0-9.]+)"!', $coordinates, $matches)) { return false; diff --git a/lib/PHPExif/Mapper/Native.php b/lib/PHPExif/Mapper/Native.php index 9ecde02..f4a5274 100644 --- a/lib/PHPExif/Mapper/Native.php +++ b/lib/PHPExif/Mapper/Native.php @@ -34,6 +34,7 @@ class Native implements MapperInterface const CREDIT = 'credit'; const EXPOSURETIME = 'ExposureTime'; const FILESIZE = 'FileSize'; + const FILENAME = 'FileName'; const FOCALLENGTH = 'FocalLength'; const FOCUSDISTANCE = 'FocusDistance'; const HEADLINE = 'headline'; @@ -52,6 +53,16 @@ class Native implements MapperInterface const YRESOLUTION = 'YResolution'; const GPSLATITUDE = 'GPSLatitude'; const GPSLONGITUDE = 'GPSLongitude'; + const GPSALTITUDE = 'GPSAltitude'; + const IMGDIRECTION = 'GPSImgDirection'; + const MAKE = 'Make'; + const LENS = 'LensInfo'; + const LENS_LR = 'UndefinedTag:0xA434'; + const LENS_TYPE = 'LensType'; + const DESCRIPTION = 'caption'; + const SUBJECT = 'subject'; + const FRAMERATE = 'framerate'; + const DURATION = 'duration'; const SECTION_FILE = 'FILE'; const SECTION_COMPUTED = 'COMPUTED'; @@ -103,6 +114,7 @@ class Native implements MapperInterface self::DATETIMEORIGINAL => Exif::CREATION_DATE, self::EXPOSURETIME => Exif::EXPOSURE, self::FILESIZE => Exif::FILESIZE, + self::FILENAME => Exif::FILENAME, self::FOCALLENGTH => Exif::FOCAL_LENGTH, self::ISOSPEEDRATINGS => Exif::ISO, self::MIMETYPE => Exif::MIMETYPE, @@ -110,8 +122,19 @@ class Native implements MapperInterface self::SOFTWARE => Exif::SOFTWARE, self::XRESOLUTION => Exif::HORIZONTAL_RESOLUTION, self::YRESOLUTION => Exif::VERTICAL_RESOLUTION, - self::GPSLATITUDE => Exif::GPS, - self::GPSLONGITUDE => Exif::GPS, + self::GPSLATITUDE => Exif::LATITUDE, + self::GPSLONGITUDE => Exif::LONGITUDE, + self::GPSALTITUDE => Exif::ALTITUDE, + self::IMGDIRECTION => Exif::IMGDIRECTION, + self::MAKE => Exif::MAKE, + self::LENS => Exif::LENS, + self::LENS_LR => Exif::LENS, + self::LENS_TYPE => Exif::LENS, + self::DESCRIPTION => Exif::DESCRIPTION, + self::SUBJECT => Exif::SUBJECT, + self::FRAMERATE => Exif::FRAMERATE, + self::DURATION => Exif::DURATION + ); /** @@ -125,6 +148,7 @@ public function mapRawData(array $data) { $mappedData = array(); $gpsData = array(); + $mappedData['description']=""; foreach ($data as $field => $value) { if ($this->isSection($field) && is_array($value)) { $subData = $this->mapRawData($value); @@ -178,10 +202,34 @@ public function mapRawData(array $data) $value = (int) reset($resolutionParts); break; case self::GPSLATITUDE: - $gpsData['lat'] = $this->extractGPSCoordinate($value); + if(!(empty($data['GPSLatitudeRef'][0]))) { + $value = $this->extractGPSCoordinate($value, $data['GPSLatitudeRef'][0]); + } break; case self::GPSLONGITUDE: - $gpsData['lon'] = $this->extractGPSCoordinate($value); + if(!(empty($data['GPSLongitudeRef'][0]))) { + $value = $this->extractGPSCoordinate($value, $data['GPSLongitudeRef'][0]); + } + break; + case self::GPSALTITUDE: + $flip = 1; + if(!(empty($data['GPSAltitudeRef'][0]))) { + $flip = ($data['GPSAltitudeRef'][0] == '1' || $data['GPSAltitudeRef'][0] == "\u{0001}") ? -1 : 1; + } + $value = $flip * $this->normalizeComponent($value); + break; + case self::IMGDIRECTION: + $value = $this->normalizeComponent($value); + break; + case self::LENS_LR: + if (!(empty($mappedData[Exif::LENS]))) { + $mappedData[Exif::LENS] = $value; + } + break; + case self::LENS_TYPE: + if (!(empty($mappedData[Exif::LENS]))) { + $mappedData[Exif::LENS] = $value; + } break; } @@ -190,17 +238,8 @@ public function mapRawData(array $data) } // add GPS coordinates, if available - if (count($gpsData) === 2) { - $latitudeRef = empty($data['GPSLatitudeRef'][0]) ? 'N' : $data['GPSLatitudeRef'][0]; - $longitudeRef = empty($data['GPSLongitudeRef'][0]) ? 'E' : $data['GPSLongitudeRef'][0]; - - $gpsLocation = sprintf( - '%s,%s', - (strtoupper($latitudeRef) === 'S' ? -1 : 1) * $gpsData['lat'], - (strtoupper($longitudeRef) === 'W' ? -1 : 1) * $gpsData['lon'] - ); - - $mappedData[Exif::GPS] = $gpsLocation; + if (!(empty($mappedData[Exif::LATITUDE])) && !(empty($mappedData[Exif::LONGITUDE]))) { + $mappedData[Exif::GPS] = sprintf('%s,%s', $mappedData[Exif::LATITUDE], $mappedData[Exif::LONGITUDE]); } else { unset($mappedData[Exif::GPS]); } @@ -249,41 +288,38 @@ protected function isFieldKnown(&$field) /** * Extract GPS coordinates from components array * - * @param array|string $components + * @param array $coordinate + * @param string $ref * @return float */ - protected function extractGPSCoordinate($components) + protected function extractGPSCoordinate(array $coordinate, string $ref) { - if (!is_array($components)) { - $components = array($components); - } - $components = array_map(array($this, 'normalizeComponent'), $components); - - if (count($components) > 2) { - return floatval($components[0]) + (floatval($components[1]) / 60) + (floatval($components[2]) / 3600); - } - - return reset($components); + $degrees = count($coordinate) > 0 ? $this->normalizeComponent($coordinate[0]) : 0; + $minutes = count($coordinate) > 1 ? $this->normalizeComponent($coordinate[1]) : 0; + $seconds = count($coordinate) > 2 ? $this->normalizeComponent($coordinate[2]) : 0; + $flip = ($ref == 'W' || $ref == 'S') ? -1 : 1; + return $flip * ($degrees + (float) $minutes / 60 + (float) $seconds / 3600); } /** * Normalize component * - * @param mixed $component - * @return int|float + * @param string $component + * @return float */ - protected function normalizeComponent($component) + protected function normalizeComponent(string $rational) { - $parts = explode('/', $component); - - if (count($parts) > 1) { - if ($parts[1]) { - return intval($parts[0]) / intval($parts[1]); - } - - return 0; - } - - return floatval(reset($parts)); + $parts = explode('/', $rational, 2); + if (count($parts) <= 0) { + return 0.0; + } + if (count($parts) == 1) { + return (float) $parts[0]; + } + // case part[1] is 0, div by 0 is forbidden. + if ($parts[1] == 0) { + return (float) 0; + } + return (float) $parts[0] / $parts[1]; } } From afb0d0acf31f11de3d67aa48e3db153dcece1a9f Mon Sep 17 00:00:00 2001 From: tmp-hallenser <1869257+tmp-hallenser@users.noreply.github.com> Date: Wed, 30 Oct 2019 12:41:38 +0100 Subject: [PATCH 02/11] Bugfixes --- lib/PHPExif/Exif.php | 28 --------------------- lib/PHPExif/Mapper/Exiftool.php | 9 +++++-- lib/PHPExif/Mapper/Native.php | 10 +++++--- tests/PHPExif/Mapper/ExiftoolMapperTest.php | 26 ++++++++++++------- tests/PHPExif/Mapper/NativeMapperTest.php | 2 +- 5 files changed, 32 insertions(+), 43 deletions(-) diff --git a/lib/PHPExif/Exif.php b/lib/PHPExif/Exif.php index 92296da..81d696d 100755 --- a/lib/PHPExif/Exif.php +++ b/lib/PHPExif/Exif.php @@ -56,7 +56,6 @@ class Exif const LATITUDE = 'latitude'; const IMGDIRECTION = 'imgDirection'; const LENS = 'lens'; - const SUBJECT = 'subject'; const CONTENTIDENTIFIER = 'contentIdentifier'; const FRAMERATE = 'framerate'; const DURATION = 'duration'; @@ -1072,33 +1071,6 @@ public function getLens() return $this->data[self::LENS]; } - /** - * Sets the subject value - * - * @param string $value - * @return \PHPExif\Exif - */ - public function setSubject($value) - { - $this->data[self::SUBJECT] = $value; - - return $this; - } - - /** - * Returns subject, if it exists - * - * @return string|boolean - */ - public function getSubject() - { - if (!isset($this->data[self::SUBJECT])) { - return false; - } - - return $this->data[self::SUBJECT]; - } - /** * Sets the content identifier value * diff --git a/lib/PHPExif/Mapper/Exiftool.php b/lib/PHPExif/Mapper/Exiftool.php index 90a0de9..8d85e93 100644 --- a/lib/PHPExif/Mapper/Exiftool.php +++ b/lib/PHPExif/Mapper/Exiftool.php @@ -118,7 +118,7 @@ class Exiftool implements MapperInterface self::IMGDIRECTION => Exif::IMGDIRECTION, self::LENS => Exif::LENS, self::DESCRIPTION => Exif::DESCRIPTION, - self::SUBJECT => Exif::SUBJECT, + self::SUBJECT => Exif::KEYWORDS, self::CONTENTIDENTIFIER => Exif::CONTENTIDENTIFIER, self::DATETIMEORIGINAL_QUICKTIME => Exif::CREATION_DATE, self::MAKE_QUICKTIME => Exif::MAKE, @@ -185,7 +185,12 @@ public function mapRawData(array $data) case self::DATETIMEORIGINAL: case self::DATETIMEORIGINAL_QUICKTIME: try { - $value = new DateTime(date('Y-m-d H:i:s', strtotime($value))); + if(!(strtotime($value)==false)) { + $value = new DateTime(date('Y-m-d H:i:s', strtotime($value))); + } else { + continue 2; + } + } catch (\Exception $exception) { continue 2; } diff --git a/lib/PHPExif/Mapper/Native.php b/lib/PHPExif/Mapper/Native.php index f4a5274..2c225c0 100644 --- a/lib/PHPExif/Mapper/Native.php +++ b/lib/PHPExif/Mapper/Native.php @@ -131,7 +131,7 @@ class Native implements MapperInterface self::LENS_LR => Exif::LENS, self::LENS_TYPE => Exif::LENS, self::DESCRIPTION => Exif::DESCRIPTION, - self::SUBJECT => Exif::SUBJECT, + self::SUBJECT => Exif::KEYWORDS, self::FRAMERATE => Exif::FRAMERATE, self::DURATION => Exif::DURATION @@ -148,7 +148,7 @@ public function mapRawData(array $data) { $mappedData = array(); $gpsData = array(); - $mappedData['description']=""; + foreach ($data as $field => $value) { if ($this->isSection($field) && is_array($value)) { $subData = $this->mapRawData($value); @@ -168,7 +168,11 @@ public function mapRawData(array $data) switch ($field) { case self::DATETIMEORIGINAL: try { - $value = new DateTime($value); + if(!(strtotime($value)==false)) { + $value = new DateTime(date('Y-m-d H:i:s', strtotime($value))); + } else { + continue 2; + } } catch (Exception $exception) { continue 2; } diff --git a/tests/PHPExif/Mapper/ExiftoolMapperTest.php b/tests/PHPExif/Mapper/ExiftoolMapperTest.php index 09d2456..c932785 100644 --- a/tests/PHPExif/Mapper/ExiftoolMapperTest.php +++ b/tests/PHPExif/Mapper/ExiftoolMapperTest.php @@ -111,7 +111,7 @@ public function testMapRawDataCorrectlyFormatsCreationDate() $result = reset($mapped); $this->assertInstanceOf('\\DateTime', $result); $this->assertEquals( - reset($rawData), + reset($rawData), $result->format('Y:m:d H:i:s') ); } @@ -184,9 +184,13 @@ public function testMapRawDataCorrectlyFormatsGPSData() ) ); - $expected = '40.333452380556,-20.167314813889'; - $this->assertCount(1, $result); - $this->assertEquals($expected, reset($result)); + $expected_gps = '40.333452380556,-20.167314813889'; + $expected_lat = '40.333452380556'; + $expected_lon = '40.333452380556'; + $this->assertCount(3, $result); + $this->assertEquals($expected_gps, $result['gps']); + $this->assertEquals($expected_lat, $result['latitude']); + $this->assertEquals($expected_lon, $result['longitude']); } /** @@ -204,9 +208,13 @@ public function testMapRawDataCorrectlyFormatsNumericGPSData() ) ); - $expected = '40.333452381,-20.167314814'; - $this->assertCount(1, $result); - $this->assertEquals($expected, reset($result)); + $expected_gps = '40.333452381,-20.167314814'; + $expected_lat = '40.333452381'; + $expected_lon = '-20.167314814'; + $this->assertCount(3, $result); + $this->assertEquals($expected_gps, $result['gps']); + $this->assertEquals($expected_lat, $result['latitude']); + $this->assertEquals($expected_lon, $result['longitude']); } /** @@ -232,7 +240,7 @@ public function testMapRawDataCorrectlyIgnoresIncorrectGPSData() * @group mapper * @covers \PHPExif\Mapper\Exiftool::mapRawData */ - public function testMapRawDataCorrectlyIgnoresIncompleteGPSData() + public function testMapRawDataOnlyLatitude() { $result = $this->mapper->mapRawData( array( @@ -241,7 +249,7 @@ public function testMapRawDataCorrectlyIgnoresIncompleteGPSData() ) ); - $this->assertCount(0, $result); + $this->assertCount(1, $result); } /** diff --git a/tests/PHPExif/Mapper/NativeMapperTest.php b/tests/PHPExif/Mapper/NativeMapperTest.php index d1c6228..4f7122d 100644 --- a/tests/PHPExif/Mapper/NativeMapperTest.php +++ b/tests/PHPExif/Mapper/NativeMapperTest.php @@ -210,7 +210,7 @@ public function testMapRawDataFlattensRawDataWithSections() * @group mapper * @covers \PHPExif\Mapper\Native::mapRawData */ - public function testMapRawDataMacthesFieldsWithoutCaseSensibilityOnFirstLetter() + public function testMapRawDataMatchesFieldsWithoutCaseSensibilityOnFirstLetter() { $rawData = array( \PHPExif\Mapper\Native::ORIENTATION => 'Portrait', From 8cbb22376d0a48c070972dda9799c634f3e4d8a8 Mon Sep 17 00:00:00 2001 From: tmp-hallenser <1869257+tmp-hallenser@users.noreply.github.com> Date: Wed, 30 Oct 2019 13:01:54 +0100 Subject: [PATCH 03/11] Bugfixes --- lib/PHPExif/Mapper/Exiftool.php | 6 +++--- lib/PHPExif/Mapper/Native.php | 11 +++++------ 2 files changed, 8 insertions(+), 9 deletions(-) diff --git a/lib/PHPExif/Mapper/Exiftool.php b/lib/PHPExif/Mapper/Exiftool.php index 8d85e93..2b986fe 100644 --- a/lib/PHPExif/Mapper/Exiftool.php +++ b/lib/PHPExif/Mapper/Exiftool.php @@ -215,14 +215,14 @@ public function mapRawData(array $data) $value = $this->extractGPSCoordinates($value); break; case self::GPSLATITUDE: - $latitudeRef = empty($data['GPS:GPSLatitudeRef']) ? 'N' : $data['GPS:GPSLatitudeRef']; + $latitudeRef = empty($data['GPS:GPSLatitudeRef']) ? 'N' : $data['GPS:GPSLatitudeRef'][0]; $value = (strtoupper($latitudeRef) === 'S' ? -1.0 : 1.0)*$this->extractGPSCoordinates($value); break; case self::GPSLONGITUDE_QUICKTIME: $value = $this->extractGPSCoordinates($value); break; case self::GPSLONGITUDE: - $longitudeRef = empty($data['GPS:GPSLongitudeRef']) ? 'E' : $data['GPS:GPSLongitudeRef']; + $longitudeRef = empty($data['GPS:GPSLongitudeRef']) ? 'E' : $data['GPS:GPSLongitudeRef'][0]; $value = (strtoupper($longitudeRef) === 'W' ? -1 : 1) * $this->extractGPSCoordinates($value); break; case self::GPSALTITUDE: @@ -289,7 +289,7 @@ public function mapRawData(array $data) */ protected function extractGPSCoordinates($coordinates) { - if (is_numeric($coordinates) === true) { + if (is_numeric($coordinates) === true or $this->numeric === true) {) { return ((float) $coordinates); } else { if (!preg_match('!^([0-9.]+) deg ([0-9.]+)\' ([0-9.]+)"!', $coordinates, $matches)) { diff --git a/lib/PHPExif/Mapper/Native.php b/lib/PHPExif/Mapper/Native.php index 2c225c0..5ef3928 100644 --- a/lib/PHPExif/Mapper/Native.php +++ b/lib/PHPExif/Mapper/Native.php @@ -206,14 +206,12 @@ public function mapRawData(array $data) $value = (int) reset($resolutionParts); break; case self::GPSLATITUDE: - if(!(empty($data['GPSLatitudeRef'][0]))) { - $value = $this->extractGPSCoordinate($value, $data['GPSLatitudeRef'][0]); - } + $GPSLatitudeRef = (!(empty($data['GPSLatitudeRef'][0]))) ? $data['GPSLatitudeRef'][0] : ''; + $value = $this->extractGPSCoordinate((array)$value, $GPSLatitudeRef); break; case self::GPSLONGITUDE: - if(!(empty($data['GPSLongitudeRef'][0]))) { - $value = $this->extractGPSCoordinate($value, $data['GPSLongitudeRef'][0]); - } + $GPSLongitudeRef = (!(empty($data['GPSLongitudeRef'][0]))) ? $data['GPSLongitudeRef'][0] : ''; + $value = $this->extractGPSCoordinate((array)$value, $GPSLongitudeRef); break; case self::GPSALTITUDE: $flip = 1; @@ -298,6 +296,7 @@ protected function isFieldKnown(&$field) */ protected function extractGPSCoordinate(array $coordinate, string $ref) { + $degrees = count($coordinate) > 0 ? $this->normalizeComponent($coordinate[0]) : 0; $minutes = count($coordinate) > 1 ? $this->normalizeComponent($coordinate[1]) : 0; $seconds = count($coordinate) > 2 ? $this->normalizeComponent($coordinate[2]) : 0; From 1b586ba9e8d3ea579eb74b91cd10f550253cba83 Mon Sep 17 00:00:00 2001 From: tmp-hallenser <1869257+tmp-hallenser@users.noreply.github.com> Date: Wed, 30 Oct 2019 13:09:33 +0100 Subject: [PATCH 04/11] Bugfix --- lib/PHPExif/Mapper/Exiftool.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/PHPExif/Mapper/Exiftool.php b/lib/PHPExif/Mapper/Exiftool.php index 2b986fe..6f3c8f3 100644 --- a/lib/PHPExif/Mapper/Exiftool.php +++ b/lib/PHPExif/Mapper/Exiftool.php @@ -289,7 +289,7 @@ public function mapRawData(array $data) */ protected function extractGPSCoordinates($coordinates) { - if (is_numeric($coordinates) === true or $this->numeric === true) {) { + if (is_numeric($coordinates) === true || $this->numeric === true) { return ((float) $coordinates); } else { if (!preg_match('!^([0-9.]+) deg ([0-9.]+)\' ([0-9.]+)"!', $coordinates, $matches)) { From 76cf2182e98e10461439ed2d7eb4d7b595b4b50d Mon Sep 17 00:00:00 2001 From: tmp-hallenser <1869257+tmp-hallenser@users.noreply.github.com> Date: Wed, 30 Oct 2019 13:47:25 +0100 Subject: [PATCH 05/11] Bugfix --- tests/PHPExif/Mapper/ExiftoolMapperTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/PHPExif/Mapper/ExiftoolMapperTest.php b/tests/PHPExif/Mapper/ExiftoolMapperTest.php index c932785..e206be5 100644 --- a/tests/PHPExif/Mapper/ExiftoolMapperTest.php +++ b/tests/PHPExif/Mapper/ExiftoolMapperTest.php @@ -186,7 +186,7 @@ public function testMapRawDataCorrectlyFormatsGPSData() $expected_gps = '40.333452380556,-20.167314813889'; $expected_lat = '40.333452380556'; - $expected_lon = '40.333452380556'; + $expected_lon = '-20.167314813889'; $this->assertCount(3, $result); $this->assertEquals($expected_gps, $result['gps']); $this->assertEquals($expected_lat, $result['latitude']); From 43c116e80a551f87c0333e25dfe8544d1764f079 Mon Sep 17 00:00:00 2001 From: tmp-hallenser <1869257+tmp-hallenser@users.noreply.github.com> Date: Fri, 1 Nov 2019 06:54:31 +0100 Subject: [PATCH 06/11] Added support for MicroVideoOffset MicroVideoOffset is used by Google Motion Photos to indicate position of video in jpg --- lib/PHPExif/Exif.php | 28 ++++++++++++++++++++++++++++ lib/PHPExif/Mapper/Exiftool.php | 2 ++ 2 files changed, 30 insertions(+) diff --git a/lib/PHPExif/Exif.php b/lib/PHPExif/Exif.php index 81d696d..020bb42 100755 --- a/lib/PHPExif/Exif.php +++ b/lib/PHPExif/Exif.php @@ -59,6 +59,7 @@ class Exif const CONTENTIDENTIFIER = 'contentIdentifier'; const FRAMERATE = 'framerate'; const DURATION = 'duration'; + const MICROVIDEOOFFSET = 'MicroVideoOffset'; /** * The mapped EXIF data @@ -1153,4 +1154,31 @@ public function getDuration() return $this->data[self::DURATION]; } + + /** + * Sets the duration value + * + * @param string $value + * @return \PHPExif\Exif + */ + public function setMicroVideoOffset($value) + { + $this->data[self::MICROVIDEOOFFSET] = $value; + + return $this; + } + + /** + * Returns duration, if it exists + * + * @return string|boolean + */ + public function getMicroVideoOffset() + { + if (!isset($this->data[self::MICROVIDEOOFFSET])) { + return false; + } + + return $this->data[self::MICROVIDEOOFFSET]; + } } diff --git a/lib/PHPExif/Mapper/Exiftool.php b/lib/PHPExif/Mapper/Exiftool.php index 6f3c8f3..f04e179 100644 --- a/lib/PHPExif/Mapper/Exiftool.php +++ b/lib/PHPExif/Mapper/Exiftool.php @@ -60,6 +60,7 @@ class Exiftool implements MapperInterface const LENS = 'ExifIFD:LensModel'; const SUBJECT = 'XMP-dc:Subject'; const CONTENTIDENTIFIER = 'Apple:ContentIdentifier'; + const MICROVIDEOOFFSET = 'XMP-GCamera:MicroVideoOffset'; const DATETIMEORIGINAL_QUICKTIME = 'QuickTime:CreationDate'; const IMAGEHEIGHT_VIDEO = 'Composite:ImageSize'; @@ -135,6 +136,7 @@ class Exiftool implements MapperInterface self::FRAMERATE_QUICKTIME_3 => Exif::FRAMERATE, self::DURATION => Exif::DURATION, self::DURATION_QUICKTIME => Exif::DURATION, + self::MICROVIDEOOFFSET => Exif::MICROVIDEOOFFSET, ); /** From 4d890db0d6af81fef052a6ca0f90a3db9519d3cf Mon Sep 17 00:00:00 2001 From: tmp-hallenser <1869257+tmp-hallenser@users.noreply.github.com> Date: Sun, 1 Dec 2019 20:57:02 +0100 Subject: [PATCH 07/11] Added FFprobe adapter, fix Travis and add additional fields 1 . FFprobe as addtional adapter - FFprobe/FFmpeg can be used as an additional adapter to extract metadata from videos. 2. Travis CI build was failing - Removed tests using PHP5, PHP7.0 and PHP7.1 (all not maintained any more) 3. Added several new fields for extraction --- .gitignore | 1 + composer.json | 19 +- lib/PHPExif/Adapter/FFprobe.php | 70 +++ lib/PHPExif/Adapter/Native.php | 218 +------- lib/PHPExif/Exif.php | 228 ++++++-- lib/PHPExif/Mapper/Exiftool.php | 98 ++-- lib/PHPExif/Mapper/FFprobe.php | 342 ++++++++++++ lib/PHPExif/Mapper/Native.php | 71 +-- lib/PHPExif/Reader/Reader.php | 5 + phpunit.xml.dist | 3 - tests/PHPExif/Adapter/AdapterAbstractTest.php | 6 +- .../PHPExif/Adapter/ExiftoolProcOpenTest.php | 12 +- tests/PHPExif/Adapter/ExiftoolTest.php | 14 +- tests/PHPExif/Adapter/FFprobeTest.php | 49 ++ tests/PHPExif/Adapter/NativeTest.php | 6 +- tests/PHPExif/ExifTest.php | 245 ++++++++- tests/PHPExif/Hydrator/MutatorTest.php | 6 +- tests/PHPExif/Mapper/ExiftoolMapperTest.php | 290 +++++++++- tests/PHPExif/Mapper/FFprobeMapperTest.php | 494 ++++++++++++++++++ tests/PHPExif/Mapper/NativeMapperTest.php | 95 +++- tests/PHPExif/Reader/ReaderTest.php | 25 +- tests/files/IMG_3824.MOV | Bin 0 -> 1429622 bytes tests/files/IMG_3825.MOV | Bin 0 -> 2322298 bytes 23 files changed, 1924 insertions(+), 373 deletions(-) create mode 100644 lib/PHPExif/Adapter/FFprobe.php create mode 100644 lib/PHPExif/Mapper/FFprobe.php create mode 100755 tests/PHPExif/Adapter/FFprobeTest.php create mode 100644 tests/PHPExif/Mapper/FFprobeMapperTest.php create mode 100644 tests/files/IMG_3824.MOV create mode 100644 tests/files/IMG_3825.MOV diff --git a/.gitignore b/.gitignore index 878da69..9a51739 100755 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ phpunit.xml tests/log vendor composer.phar +.DS_Store diff --git a/composer.json b/composer.json index a10b837..16eb9cc 100755 --- a/composer.json +++ b/composer.json @@ -10,21 +10,24 @@ "role": "Developer" } ], - "keywords": ["EXIF", "IPTC", "jpeg", "tiff", "exiftool"], + "keywords": ["EXIF", "IPTC", "jpeg", "tiff", "exiftool", "FFmpeg", "FFprobe"], "require": { - "php": ">=5.4" + "php": ">=7.1", + "php-ffmpeg/php-ffmpeg": "^0.14.0" }, "require-dev": { "jakub-onderka/php-parallel-lint": "^1.0", - "phpmd/phpmd": "~2.2", - "phpunit/phpunit": ">=4.0 <6.0", - "satooshi/php-coveralls": "~0.6", - "sebastian/phpcpd": "1.4.*@stable", - "squizlabs/php_codesniffer": "1.4.*@stable" + "php-coveralls/php-coveralls": "^2.2", + "phpmd/phpmd": "^2.7", + "phpunit/phpunit": ">=8.4", + "sebastian/phpcpd": "^4.1", + "friendsofphp/php-cs-fixer": "^2.16", + "squizlabs/php_codesniffer": "^3.5" }, "suggest": { "lib-exiftool": "Use perl lib exiftool as adapter", - "ext-exif": "Use exif PHP extension as adapter" + "ext-exif": "Use exif PHP extension as adapter", + "FFmpeg": "Use FFmpeg/FFprobe as adapter" }, "autoload": { "psr-0": { diff --git a/lib/PHPExif/Adapter/FFprobe.php b/lib/PHPExif/Adapter/FFprobe.php new file mode 100644 index 0000000..7dadc2e --- /dev/null +++ b/lib/PHPExif/Adapter/FFprobe.php @@ -0,0 +1,70 @@ + + * @license http://github.com/miljar/PHPExif/blob/master/LICENSE MIT License + * @category PHPExif + * @package Reader + */ + +namespace PHPExif\Adapter; + +use PHPExif\Exif; +use FFMpeg; + +/** + * PHP Exif Native Reader Adapter + * + * Uses native PHP functionality to read data from a file + * + * @category PHPExif + * @package Reader + */ +class FFprobe extends AdapterAbstract +{ + + /** + * @var string + */ + protected $mapperClass = '\\PHPExif\\Mapper\\FFprobe'; + + + /** + * Reads & parses the EXIF data from given file + * + * @param string $file + * @return \PHPExif\Exif|boolean Instance of Exif object with data + */ + public function getExifFromFile($file) + { + $mimeType = mime_content_type($file); + + // file is not a video -> wrong adapter + if (strpos($mimeType, 'video') !== 0) { + return false; + } + + + $ffprobe = FFMpeg\FFProbe::create(); + + $stream = $ffprobe->streams($file)->videos()->first()->all(); + $format = $ffprobe->format($file)->all(); + + $data = array_merge($stream, $format, array('MimeType' => $mimeType, 'filesize' => filesize($file))); + + + // map the data: + $mapper = $this->getMapper(); + $mappedData = $mapper->mapRawData($data); + + // hydrate a new Exif object + $exif = new Exif(); + $hydrator = $this->getHydrator(); + $hydrator->hydrate($exif, $mappedData); + $exif->setRawData($data); + + return $exif; + } +} diff --git a/lib/PHPExif/Adapter/Native.php b/lib/PHPExif/Adapter/Native.php index cf5d117..076049d 100644 --- a/lib/PHPExif/Adapter/Native.php +++ b/lib/PHPExif/Adapter/Native.php @@ -71,14 +71,18 @@ class Native extends AdapterAbstract * @var array */ protected $iptcMapping = array( - 'title' => '2#005', - 'keywords' => '2#025', - 'copyright' => '2#116', - 'caption' => '2#120', - 'headline' => '2#105', - 'credit' => '2#110', - 'source' => '2#115', - 'jobtitle' => '2#085' + 'title' => '2#005', + 'keywords' => '2#025', + 'copyright' => '2#116', + 'caption' => '2#120', + 'headline' => '2#105', + 'credit' => '2#110', + 'source' => '2#115', + 'jobtitle' => '2#085', + 'city' => '2#090', + 'sublocation' => '2#092', + 'state' => '2#095', + 'country' => '2#101' ); @@ -176,38 +180,27 @@ public function getExifFromFile($file) { $mimeType = mime_content_type($file); - if (strpos($mimeType, 'video') !== 0) { - // Photo - $sections = $this->getRequiredSections(); - $sections = implode(',', $sections); - $sections = (empty($sections)) ? null : $sections; - $data = @exif_read_data( - $file, - $sections, - $this->getSectionsAsArrays(), - $this->getIncludeThumbnail() - ); + // Photo + $sections = $this->getRequiredSections(); + $sections = implode(',', $sections); + $sections = (empty($sections)) ? null : $sections; - if (false === $data) { - return false; - } + $data = @exif_read_data( + $file, + $sections, + $this->getSectionsAsArrays(), + $this->getIncludeThumbnail() + ); - $xmpData = $this->getIptcData($file); - $data = array_merge($data, array(self::SECTION_IPTC => $xmpData)); - - } else { - // Video - try { + if (false === $data) { + return false; + } - $data = $this->getVideoData($file); - $data['MimeType'] = $mimeType; + $xmpData = $this->getIptcData($file); + $data = array_merge($data, array(self::SECTION_IPTC => $xmpData)); - } catch (Exception $exception) { - Logs::error(__METHOD__, __LINE__, $exception->getMessage()); - } - } // map the data: $mapper = $this->getMapper(); @@ -222,163 +215,6 @@ public function getExifFromFile($file) return $exif; } - /** - * Returns an array of video data - * - * @param string $file The file to read the video data from - * @return array - */ - public function getVideoData($filename) - { - - $metadata['FileSize'] = filesize($filename); - - $path_ffmpeg = exec('which ffmpeg'); - $path_ffprobe = exec('which ffprobe'); - $ffprobe = FFMpeg\FFProbe::create(array( - 'ffmpeg.binaries' => $path_ffmpeg, - 'ffprobe.binaries' => $path_ffprobe, - )); - - $stream = $ffprobe->streams($filename)->videos()->first()->all(); - $format = $ffprobe->format($filename)->all(); - if (isset($stream['width'])) { - $metadata['Width'] = $stream['width']; - } - if (isset($stream['height'])) { - $metadata['Height'] = $stream['height']; - } - if (isset($stream['tags']) && isset($stream['tags']['rotate']) && ($stream['tags']['rotate'] === '90' || $stream['tags']['rotate'] === '270')) { - $tmp = $metadata['Width']; - $metadata['Width'] = $metadata['Height']; - $metadata['Height'] = $tmp; - } - if (isset($stream['avg_frame_rate'])) { - $framerate = explode('/', $stream['avg_frame_rate']); - if (count($framerate) == 1) { - $framerate = $framerate[0]; - } elseif (count($framerate) == 2 && $framerate[1] != 0) { - $framerate = number_format($framerate[0] / $framerate[1], 3); - } else { - $framerate = ''; - } - if ($framerate !== '') { - $metadata['framerate'] = $framerate; - } - } - if (isset($format['duration'])) { - $metadata['duration'] = number_format($format['duration'], 3); - } - if (isset($format['tags'])) { - if (isset($format['tags']['creation_time']) && strtotime($format['tags']['creation_time']) !== 0) { - $metadata['DateTimeOriginal'] = date('Y-m-d H:i:s', strtotime($format['tags']['creation_time'])); - } - if (isset($format['tags']['location'])) { - $matches = []; - preg_match('/^([+-][0-9\.]+)([+-][0-9\.]+)\/$/', $format['tags']['location'], $matches); - if (count($matches) == 3 && - !preg_match('/^\+0+\.0+$/', $matches[1]) && - !preg_match('/^\+0+\.0+$/', $matches[2])) { - $metadata['GPSLatitude'] = $matches[1]; - $metadata['GPSLongitude'] = $matches[2]; - } - } - // QuickTime File Format defines several additional metadata - // Source: https://developer.apple.com/library/archive/documentation/QuickTime/QTFF/Metadata/Metadata.html - // Special case: iPhones write into tags->creation_time the creation time of the file - // -> When converting the video from HEVC (iOS Video format) to MOV, the creation_time - // is the time when the mov file was created, not when the video was shot (fixed in iOS12) - // (see e.g. https://michaelkummer.com/tech/apple/photos-videos-wrong-date/ (for the symptom) - // Solution: Use com.apple.quicktime.creationdate which is the true creation date of the video - if (isset($format['tags']['com.apple.quicktime.creationdate'])) { - $metadata['DateTimeOriginal'] = date('Y-m-d H:i:s', strtotime($format['tags']['com.apple.quicktime.creationdate'])); - } - if (isset($format['tags']['com.apple.quicktime.description'])) { - $metadata['description'] = $format['tags']['com.apple.quicktime.description']; - } - if (isset($format['tags']['com.apple.quicktime.title'])) { - $metadata['title'] = $format['tags']['com.apple.quicktime.title']; - } - if (isset($format['tags']['com.apple.quicktime.keywords'])) { - $metadata['keywords'] = $format['tags']['com.apple.quicktime.keywords']; - } - if (isset($format['tags']['com.apple.quicktime.location.ISO6709'])) { - $location_data = $this->readISO6709($format['tags']['com.apple.quicktime.location.ISO6709']); - $metadata['GPSLatitude'] = $location_data['latitude']; - $metadata['GPSLongitude'] = $location_data['longitude']; - $metadata['GPSAltitude'] = $location_data['altitude']; - } - // Not documented, but available on iPhone videos - if (isset($format['tags']['com.apple.quicktime.make'])) { - $metadata['Make'] = $format['tags']['com.apple.quicktime.make']; - } - // Not documented, but available on iPhone videos - if (isset($format['tags']['com.apple.quicktime.model'])) { - $metadata['Model'] = $format['tags']['com.apple.quicktime.model']; - } - } - - return $metadata; - } - - /** - * Converts results of ISO6709 parsing - * to decimal format for latitude and longitude - * See https://github.com/seanson/python-iso6709.git. - * - * @param string sign - * @param string degrees - * @param string minutes - * @param string seconds - * @param string fraction - * - * @return float - */ - private function convertDMStoDecimal(string $sign, string $degrees, string $minutes, string $seconds, string $fraction): float - { - if ($fraction !== '') { - if ($seconds !== '') { - $seconds = $seconds . $fraction; - } elseif ($minutes !== '') { - $minutes = $minutes . $fraction; - } else { - $degrees = $degrees . $fraction; - } - } - $decimal = floatval($degrees) + floatval($minutes) / 60.0 + floatval($seconds) / 3600.0; - if ($sign == '-') { - $decimal = -1.0 * $decimal; - } - return $decimal; - } - - /** - * Returns the latitude, longitude and altitude - * of a GPS coordiante formattet with ISO6709 - * See https://github.com/seanson/python-iso6709.git. - * - * @param string val_ISO6709 - * - * @return array - */ - private function readISO6709(string $val_ISO6709): array - { - $return = [ - 'latitude' => null, - 'longitude' => null, - 'altitude' => null, - ]; - $matches = []; - // Adjustment compared to https://github.com/seanson/python-iso6709.git - // Altitude have format +XX.XXXX -> Adjustment for decimal - preg_match('/^(?\+|-)(?[0,1]?\d{2})(?\d{2}?)?(?\d{2}?)?(?\.\d+)?(?\+|-)(?[0,1]?\d{2})(?\d{2}?)?(?\d{2}?)?(?\.\d+)?(?[\+\-][0-9]\d*(\.\d+)?)?\/$/', $val_ISO6709, $matches); - $return['latitude'] = $this->convertDMStoDecimal($matches['lat_sign'], $matches['lat_degrees'], $matches['lat_minutes'], $matches['lat_seconds'], $matches['lat_fraction']); - $return['longitude'] = $this->convertDMStoDecimal($matches['lng_sign'], $matches['lng_degrees'], $matches['lng_minutes'], $matches['lng_seconds'], $matches['lng_fraction']); - if (isset($matches['alt'])) { - $return['altitude'] = doubleval($matches['alt']); - } - return $return; - } /** * Returns an array of IPTC data diff --git a/lib/PHPExif/Exif.php b/lib/PHPExif/Exif.php index 020bb42..bf4469c 100755 --- a/lib/PHPExif/Exif.php +++ b/lib/PHPExif/Exif.php @@ -22,44 +22,50 @@ */ class Exif { + const ALTITUDE = 'altitude'; const APERTURE = 'aperture'; const AUTHOR = 'author'; const CAMERA = 'camera'; const CAPTION = 'caption'; + const CITY = 'city'; const COLORSPACE = 'ColorSpace'; + const CONTENTIDENTIFIER = 'contentIdentifier'; const COPYRIGHT = 'copyright'; + const COUNTRY = 'country'; const CREATION_DATE = 'creationdate'; const CREDIT = 'credit'; + const DESCRIPTION = 'description'; + const DURATION = 'duration'; const EXPOSURE = 'exposure'; const FILESIZE = 'FileSize'; const FILENAME = 'FileName'; const FOCAL_LENGTH = 'focalLength'; const FOCAL_DISTANCE = 'focalDistance'; + const FRAMERATE = 'framerate'; + const GPS = 'gps'; const HEADLINE = 'headline'; const HEIGHT = 'height'; const HORIZONTAL_RESOLUTION = 'horizontalResolution'; + const IMGDIRECTION = 'imgDirection'; const ISO = 'iso'; const JOB_TITLE = 'jobTitle'; const KEYWORDS = 'keywords'; + const LATITUDE = 'latitude'; + const LONGITUDE = 'longitude'; + const LENS = 'lens'; + const MAKE = 'make'; + const MICROVIDEOOFFSET = 'MicroVideoOffset'; const MIMETYPE = 'MimeType'; const ORIENTATION = 'Orientation'; const SOFTWARE = 'software'; const SOURCE = 'source'; + const STATE = 'state'; + const SUBLOCATION = 'Sublocation'; const TITLE = 'title'; const VERTICAL_RESOLUTION = 'verticalResolution'; const WIDTH = 'width'; - const GPS = 'gps'; - const ALTITUDE = 'altitude'; - const DESCRIPTION = 'description'; - const MAKE = 'make'; - const LONGITUDE = 'longitude'; - const LATITUDE = 'latitude'; - const IMGDIRECTION = 'imgDirection'; - const LENS = 'lens'; - const CONTENTIDENTIFIER = 'contentIdentifier'; - const FRAMERATE = 'framerate'; - const DURATION = 'duration'; - const MICROVIDEOOFFSET = 'MicroVideoOffset'; + + /** * The mapped EXIF data @@ -401,7 +407,6 @@ public function setFocusDistance($value) */ public function getWidth() { - if (!isset($this->data[self::WIDTH])) { return false; } @@ -1128,57 +1133,164 @@ public function getFramerate() } - /** - * Sets the duration value - * - * @param string $value - * @return \PHPExif\Exif - */ - public function setDuration($value) - { - $this->data[self::DURATION] = $value; + /** + * Sets the duration value + * + * @param string $value + * @return \PHPExif\Exif + */ + public function setDuration($value) + { + $this->data[self::DURATION] = $value; - return $this; + return $this; + } + + /** + * Returns duration, if it exists + * + * @return string|boolean + */ + public function getDuration() + { + if (!isset($this->data[self::DURATION])) { + return false; } + return $this->data[self::DURATION]; + } + + /** + * Sets the duration value + * + * @param string $value + * @return \PHPExif\Exif + */ + public function setMicroVideoOffset($value) + { + $this->data[self::MICROVIDEOOFFSET] = $value; + + return $this; + } - /** - * Returns duration, if it exists - * - * @return string|boolean - */ - public function getDuration() - { - if (!isset($this->data[self::DURATION])) { - return false; - } - - return $this->data[self::DURATION]; + /** + * Returns duration, if it exists + * + * @return string|boolean + */ + public function getMicroVideoOffset() + { + if (!isset($this->data[self::MICROVIDEOOFFSET])) { + return false; } - /** - * Sets the duration value - * - * @param string $value - * @return \PHPExif\Exif - */ - public function setMicroVideoOffset($value) - { - $this->data[self::MICROVIDEOOFFSET] = $value; - - return $this; + return $this->data[self::MICROVIDEOOFFSET]; + } + + /** + * Sets the sublocation value + * + * @param string $value + * @return \PHPExif\Exif + */ + public function setSublocation($value) + { + $this->data[self::SUBLOCATION] = $value; + + return $this; + } + + /** + * Returns sublocation, if it exists + * + * @return string|boolean + */ + public function getSublocation() + { + if (!isset($this->data[self::SUBLOCATION])) { + return false; + } + + return $this->data[self::SUBLOCATION]; + } + + /** + * Sets the city value + * + * @param string $value + * @return \PHPExif\Exif + */ + public function setCity($value) + { + $this->data[self::CITY] = $value; + + return $this; + } + + /** + * Returns city, if it exists + * + * @return string|boolean + */ + public function getCity() + { + if (!isset($this->data[self::CITY])) { + return false; + } + + return $this->data[self::CITY]; + } + + /** + * Sets the state value + * + * @param string $value + * @return \PHPExif\Exif + */ + public function setState($value) + { + $this->data[self::STATE] = $value; + + return $this; + } + + /** + * Returns state, if it exists + * + * @return string|boolean + */ + public function getState() + { + if (!isset($this->data[self::STATE])) { + return false; } - /** - * Returns duration, if it exists - * - * @return string|boolean - */ - public function getMicroVideoOffset() - { - if (!isset($this->data[self::MICROVIDEOOFFSET])) { - return false; - } - - return $this->data[self::MICROVIDEOOFFSET]; + return $this->data[self::STATE]; + } + + /** + * Sets the country value + * + * @param string $value + * @return \PHPExif\Exif + */ + public function setCountry($value) + { + $this->data[self::COUNTRY] = $value; + + return $this; + } + + /** + * Returns country, if it exists + * + * @return string|boolean + */ + public function getCountry() + { + if (!isset($this->data[self::COUNTRY])) { + return false; } + + return $this->data[self::COUNTRY]; + } } diff --git a/lib/PHPExif/Mapper/Exiftool.php b/lib/PHPExif/Mapper/Exiftool.php index f04e179..20c8e63 100644 --- a/lib/PHPExif/Mapper/Exiftool.php +++ b/lib/PHPExif/Mapper/Exiftool.php @@ -61,6 +61,10 @@ class Exiftool implements MapperInterface const SUBJECT = 'XMP-dc:Subject'; const CONTENTIDENTIFIER = 'Apple:ContentIdentifier'; const MICROVIDEOOFFSET = 'XMP-GCamera:MicroVideoOffset'; + const SUBLOCATION = 'IPTC2:Sublocation'; + const CITY = 'IPTC2:City'; + const STATE = 'IPTC2:Province-State'; + const COUNTRY = 'IPTC2:Country-PrimaryLocationName'; const DATETIMEORIGINAL_QUICKTIME = 'QuickTime:CreationDate'; const IMAGEHEIGHT_VIDEO = 'Composite:ImageSize'; @@ -137,6 +141,10 @@ class Exiftool implements MapperInterface self::DURATION => Exif::DURATION, self::DURATION_QUICKTIME => Exif::DURATION, self::MICROVIDEOOFFSET => Exif::MICROVIDEOOFFSET, + self::SUBLOCATION => Exif::SUBLOCATION, + self::CITY => Exif::CITY, + self::STATE => Exif::STATE, + self::COUNTRY => Exif::COUNTRY ); /** @@ -185,17 +193,31 @@ public function mapRawData(array $data) $value = sprintf('%1$sm', $value); break; case self::DATETIMEORIGINAL: - case self::DATETIMEORIGINAL_QUICKTIME: - try { - if(!(strtotime($value)==false)) { - $value = new DateTime(date('Y-m-d H:i:s', strtotime($value))); - } else { - continue 2; + // QUICKTIME_DATE contains data on timezone + // only set value if QUICKTIME_DATE has not been used + if (!isset($mappedData[Exif::CREATION_DATE])) { + try { + if (isset($data['ExifIFD:OffsetTimeOriginal'])) { + $timezone = new \DateTimeZone($data['ExifIFD:OffsetTimeOriginal']); + $value = new \DateTime($value, $timezone); + } else { + $value = new \DateTime($value); + } + } catch (\Exception $e) { + continue 2; } + } else { + continue 2; + } - } catch (\Exception $exception) { + break; + case self::DATETIMEORIGINAL_QUICKTIME: + try { + $value = new DateTime($value); + } catch (\Exception $e) { continue 2; } + break; case self::EXPOSURETIME: // Based on the source code of Exiftool (PrintExposureTime subroutine): @@ -218,53 +240,61 @@ public function mapRawData(array $data) break; case self::GPSLATITUDE: $latitudeRef = empty($data['GPS:GPSLatitudeRef']) ? 'N' : $data['GPS:GPSLatitudeRef'][0]; - $value = (strtoupper($latitudeRef) === 'S' ? -1.0 : 1.0)*$this->extractGPSCoordinates($value); + $value = $this->extractGPSCoordinates($value); + if ($value !== false) { + $value = (strtoupper($latitudeRef) === 'S' ? -1.0 : 1.0) * $value; + } else { + $value = false; + } + break; case self::GPSLONGITUDE_QUICKTIME: $value = $this->extractGPSCoordinates($value); break; case self::GPSLONGITUDE: $longitudeRef = empty($data['GPS:GPSLongitudeRef']) ? 'E' : $data['GPS:GPSLongitudeRef'][0]; - $value = (strtoupper($longitudeRef) === 'W' ? -1 : 1) * $this->extractGPSCoordinates($value); + $value = $this->extractGPSCoordinates($value); + if ($value !== false) { + $value = (strtoupper($longitudeRef) === 'W' ? -1 : 1) * $value; + } + break; case self::GPSALTITUDE: $flip = 1; - if(!(empty($data['GPS:GPSAltitudeRef']))) { - $flip = ($data['GPS:GPSAltitudeRef'] == '1') ? -1 : 1; + if (!(empty($data['GPS:GPSAltitudeRef']))) { + $flip = ($data['GPS:GPSAltitudeRef'] == '1') ? -1 : 1; } - $value = $flip * (float) $value; + $value = $flip * (float) $value; break; case self::GPSALTITUDE_QUICKTIME: $flip = 1; - if(!(empty($data['Composite:GPSAltitudeRef']))) { - $flip = ($data['Composite:GPSAltitudeRef'] == '1') ? -1 : 1; + if (!(empty($data['Composite:GPSAltitudeRef']))) { + $flip = ($data['Composite:GPSAltitudeRef'] == '1') ? -1 : 1; } $value = $flip * (float) $value; break; case self::IMAGEHEIGHT_VIDEO: case self::IMAGEWIDTH_VIDEO: $value_splitted = explode("x", $value); - if(empty($mappedData[Exif::WIDTH])) { - if(!(empty($data['Composite:Rotation']))) { + $rotate = false; + if (!(empty($data['Composite:Rotation']))) { if ($data['Composite:Rotation']=='90' || $data['Composite:Rotation']=='270') { - $mappedData[Exif::WIDTH] = intval($value_splitted[1]); + $rotate = true; + } + } + if (empty($mappedData[Exif::WIDTH])) { + if (!($rotate)) { + $mappedData[Exif::WIDTH] = intval($value_splitted[0]); } else { - $mappedData[Exif::WIDTH] = intval($value_splitted[0]); + $mappedData[Exif::WIDTH] = intval($value_splitted[1]); } - } else { - $mappedData[Exif::WIDTH] = intval($value_splitted[0]); - } } - if(empty($mappedData[Exif::HEIGHT])) { - if(!(empty($data['Composite:Rotation']))) { - if ($data['Composite:Rotation']=='90' || $data['Composite:Rotation']=='270') { - $mappedData[Exif::HEIGHT] = intval($value_splitted[0]); + if (empty($mappedData[Exif::HEIGHT])) { + if (!($rotate)) { + $mappedData[Exif::HEIGHT] = intval($value_splitted[1]); } else { - $mappedData[Exif::HEIGHT] = intval($value_splitted[1]); + $mappedData[Exif::HEIGHT] = intval($value_splitted[0]); } - } else { - $mappedData[Exif::HEIGHT] = intval($value_splitted[1]); - } } continue 2; break; @@ -274,8 +304,12 @@ public function mapRawData(array $data) } // add GPS coordinates, if available - if (!(empty($mappedData[Exif::LATITUDE])) && !(empty($mappedData[Exif::LONGITUDE]))) { - $mappedData[Exif::GPS] = sprintf('%s,%s', $mappedData[Exif::LATITUDE], $mappedData[Exif::LONGITUDE]); + if ((isset($mappedData[Exif::LATITUDE])) && (isset($mappedData[Exif::LONGITUDE]))) { + if (($mappedData[Exif::LATITUDE]!==false) && $mappedData[Exif::LONGITUDE]!==false) { + $mappedData[Exif::GPS] = sprintf('%s,%s', $mappedData[Exif::LATITUDE], $mappedData[Exif::LONGITUDE]); + } else { + $mappedData[Exif::GPS] = false; + } } else { unset($mappedData[Exif::GPS]); } @@ -291,7 +325,7 @@ public function mapRawData(array $data) */ protected function extractGPSCoordinates($coordinates) { - if (is_numeric($coordinates) === true || $this->numeric === true) { + if (is_numeric($coordinates) === true || $this->numeric === true) { return ((float) $coordinates); } else { if (!preg_match('!^([0-9.]+) deg ([0-9.]+)\' ([0-9.]+)"!', $coordinates, $matches)) { diff --git a/lib/PHPExif/Mapper/FFprobe.php b/lib/PHPExif/Mapper/FFprobe.php new file mode 100644 index 0000000..75972bd --- /dev/null +++ b/lib/PHPExif/Mapper/FFprobe.php @@ -0,0 +1,342 @@ + + * @license http://github.com/miljar/PHPExif/blob/master/LICENSE MIT License + * @category PHPExif + * @package Mapper + */ + +namespace PHPExif\Mapper; + +use PHPExif\Exif; +use DateTime; +use Exception; + +/** + * PHP Exif Native Mapper + * + * Maps native raw data to valid data for the \PHPExif\Exif class + * + * @category PHPExif + * @package Mapper + */ +class FFprobe implements MapperInterface +{ + const HEIGHT = 'height'; + const WIDTH = 'width'; + const FILESIZE = 'size'; + const FILENAME = 'filename'; + const FRAMERATE = 'avg_frame_rate'; + const DURATION = 'duration'; + const DATETIMEORIGINAL = 'creation_time'; + const GPSLATITUDE = 'location'; + const GPSLONGITUDE = 'location'; + const MIMETYPE = 'MimeType'; + + const QUICKTIME_GPSLATITUDE = 'com.apple.quicktime.location.ISO6709'; + const QUICKTIME_GPSLONGITUDE = 'com.apple.quicktime.location.ISO6709'; + const QUICKTIME_GPSALTITUDE = 'com.apple.quicktime.location.ISO6709'; + const QUICKTIME_DATE = 'com.apple.quicktime.creationdate'; + const QUICKTIME_DESCRIPTION = 'com.apple.quicktime.description'; + const QUICKTIME_TITLE = 'com.apple.quicktime.title'; + const QUICKTIME_KEYWORDS = 'com.apple.quicktime.keywords'; + const QUICKTIME_MAKE = 'com.apple.quicktime.make'; + const QUICKTIME_MODEL = 'com.apple.quicktime.model'; + const QUICKTIME_CONTENTIDENTIFIER = 'com.apple.quicktime.content.identifier'; + + + /** + * Maps the ExifTool fields to the fields of + * the \PHPExif\Exif class + * + * @var array + */ + protected $map = array( + self::HEIGHT => Exif::HEIGHT, + self::WIDTH => Exif::WIDTH, + self::DATETIMEORIGINAL => Exif::CREATION_DATE, + self::FILESIZE => Exif::FILESIZE, + self::FILENAME => Exif::FILENAME, + self::MIMETYPE => Exif::MIMETYPE, + self::GPSLATITUDE => Exif::LATITUDE, + self::GPSLONGITUDE => Exif::LONGITUDE, + self::FRAMERATE => Exif::FRAMERATE, + self::DURATION => Exif::DURATION, + + self::QUICKTIME_DATE => Exif::CREATION_DATE, + self::QUICKTIME_DESCRIPTION => Exif::DESCRIPTION, + self::QUICKTIME_MAKE => Exif::MAKE, + self::QUICKTIME_TITLE => Exif::TITLE, + self::QUICKTIME_MODEL => Exif::CAMERA, + self::QUICKTIME_KEYWORDS => Exif::KEYWORDS, + self::QUICKTIME_GPSLATITUDE => Exif::LATITUDE, + self::QUICKTIME_GPSLONGITUDE => Exif::LONGITUDE, + self::QUICKTIME_GPSALTITUDE => Exif::ALTITUDE, + self::QUICKTIME_CONTENTIDENTIFIER => Exif::CONTENTIDENTIFIER, + ); + + const SECTION_TAGS = 'tags'; + + /** + * A list of section names + * + * @var array + */ + protected $sections = array( + self::SECTION_TAGS + ); + + /** + * Maps the array of raw source data to the correct + * fields for the \PHPExif\Exif class + * + * @param array $data + * @return array + */ + public function mapRawData(array $data) + { + $mappedData = array(); + $gpsData = array(); + + foreach ($data as $field => $value) { + if ($this->isSection($field) && is_array($value)) { + $subData = $this->mapRawData($value); + + $mappedData = array_merge($mappedData, $subData); + continue; + } + if (!$this->isFieldKnown($field)) { + // silently ignore unknown fields + continue; + } + + $key = $this->map[$field]; + + // manipulate the value if necessary + switch ($field) { + case self::DATETIMEORIGINAL: + // QUICKTIME_DATE contains data on timezone + // only set value if QUICKTIME_DATE has not been used + if (!isset($mappedData[Exif::CREATION_DATE])) { + try { + $value = new DateTime($value); + } catch (\Exception $e) { + continue 2; + } + } else { + continue 2; + } + + break; + case self::QUICKTIME_DATE: + try { + $value = new DateTime($value); + } catch (\Exception $e) { + continue 2; + } + + break; + case self::FRAMERATE: + $value = $this->normalizeComponent($value); + break; + case self::GPSLATITUDE: + case self::GPSLONGITUDE: + $matches = []; + preg_match('/^([+-][0-9\.]+)([+-][0-9\.]+)\/$/', $value, $matches); + if (count($matches) == 3 && + !preg_match('/^\+0+\.0+$/', $matches[1]) && + !preg_match('/^\+0+\.0+$/', $matches[2])) { + $mappedData[Exif::LATITUDE] = $matches[1]; + $mappedData[Exif::LONGITUDE] = $matches[2]; + } + continue 2; + case self::QUICKTIME_GPSALTITUDE: + case self::QUICKTIME_GPSLATITUDE: + case self::QUICKTIME_GPSLONGITUDE: + $location_data = $this->readISO6709($value); + $mappedData[Exif::LATITUDE] = $location_data['latitude']; + $mappedData[Exif::LONGITUDE] = $location_data['longitude']; + $mappedData[Exif::ALTITUDE] = $location_data['altitude']; + //$value = $this->normalizeComponent($value); + continue 2; + } + + // set end result + $mappedData[$key] = $value; + } + + // add GPS coordinates, if available + if ((isset($mappedData[Exif::LATITUDE])) && (isset($mappedData[Exif::LONGITUDE]))) { + $mappedData[Exif::GPS] = sprintf('%s,%s', $mappedData[Exif::LATITUDE], $mappedData[Exif::LONGITUDE]); + } else { + unset($mappedData[Exif::GPS]); + } + + // Swap width and height if needed + if (isset($data['tags']) && isset($data['tags']['rotate']) + && ($data['tags']['rotate'] === '90' || $data['tags']['rotate'] === '270')) { + $tmp = $mappedData[Exif::WIDTH]; + $mappedData[Exif::WIDTH] = $mappedData[Exif::HEIGHT]; + $mappedData[Exif::HEIGHT] = $tmp; + } + + return $mappedData; + } + + /** + * Determines if given field is a section + * + * @param string $field + * @return bool + */ + protected function isSection($field) + { + return (in_array($field, $this->sections)); + } + + /** + * Determines if the given field is known, + * in a case insensitive way for its first letter. + * Also update $field to keep it valid against the known fields. + * + * @param string &$field + * @return bool + */ + protected function isFieldKnown(&$field) + { + $lcfField = lcfirst($field); + if (array_key_exists($lcfField, $this->map)) { + $field = $lcfField; + + return true; + } + + $ucfField = ucfirst($field); + if (array_key_exists($ucfField, $this->map)) { + $field = $ucfField; + + return true; + } + + return false; + } + + /** + * Normalize component + * + * @param string $component + * @return float + */ + protected function normalizeComponent($rational) + { + $parts = explode('/', $rational, 2); + if (count($parts) == 1) { + return (float) $parts[0]; + } + // case part[1] is 0, div by 0 is forbidden. + if ($parts[1] == 0) { + return (float) 0; + } + return (float) $parts[0] / $parts[1]; + } + + + /** + + * Converts results of ISO6709 parsing + * to decimal format for latitude and longitude + * See https://github.com/seanson/python-iso6709.git. + * + * @param string sign + * @param string degrees + * @param string minutes + * @param string seconds + * @param string fraction + * + * @return float + */ + public function convertDMStoDecimal( + string $sign, + string $degrees, + string $minutes, + string $seconds, + string $fraction + ) { + if ($fraction !== '') { + if ($seconds !== '') { + $seconds = $seconds . $fraction; + } elseif ($minutes !== '') { + $minutes = $minutes . $fraction; + } else { + $degrees = $degrees . $fraction; + } + } + $decimal = floatval($degrees) + floatval($minutes) / 60.0 + floatval($seconds) / 3600.0; + if ($sign == '-') { + $decimal = -1.0 * $decimal; + } + return $decimal; + } + + /** + * Returns the latitude, longitude and altitude + * of a GPS coordiante formattet with ISO6709 + * See https://github.com/seanson/python-iso6709.git. + * + * @param string val_ISO6709 + * + * @return array + */ + public function readISO6709(string $val_ISO6709) + { + $return = [ + 'latitude' => null, + 'longitude' => null, + 'altitude' => null, + ]; + $matches = []; + // Adjustment compared to https://github.com/seanson/python-iso6709.git + // Altitude have format +XX.XXXX -> Adjustment for decimal + + preg_match( + '/^(?\+|-)' . + '(?[0,1]?\d{2})' . + '(?\d{2}?)?' . + '(?\d{2}?)?' . + '(?\.\d+)?' . + '(?\+|-)' . + '(?[0,1]?\d{2})' . + '(?\d{2}?)?' . + '(?\d{2}?)?' . + '(?\.\d+)?' . + '(?[\+\-][0-9]\d*(\.\d+)?)?\/$/', + $val_ISO6709, + $matches + ); + + $return['latitude'] = + $this->convertDMStoDecimal( + $matches['lat_sign'], + $matches['lat_degrees'], + $matches['lat_minutes'], + $matches['lat_seconds'], + $matches['lat_fraction'] + ); + + $return['longitude'] = + $this->convertDMStoDecimal( + $matches['lng_sign'], + $matches['lng_degrees'], + $matches['lng_minutes'], + $matches['lng_seconds'], + $matches['lng_fraction'] + ); + if (isset($matches['alt'])) { + $return['altitude'] = doubleval($matches['alt']); + } + return $return; + } +} diff --git a/lib/PHPExif/Mapper/Native.php b/lib/PHPExif/Mapper/Native.php index 5ef3928..3e99548 100644 --- a/lib/PHPExif/Mapper/Native.php +++ b/lib/PHPExif/Mapper/Native.php @@ -63,6 +63,10 @@ class Native implements MapperInterface const SUBJECT = 'subject'; const FRAMERATE = 'framerate'; const DURATION = 'duration'; + const CITY = 'city'; + const SUBLOCATION = 'sublocation'; + const STATE = 'state'; + const COUNTRY = 'country'; const SECTION_FILE = 'FILE'; const SECTION_COMPUTED = 'COMPUTED'; @@ -133,7 +137,11 @@ class Native implements MapperInterface self::DESCRIPTION => Exif::DESCRIPTION, self::SUBJECT => Exif::KEYWORDS, self::FRAMERATE => Exif::FRAMERATE, - self::DURATION => Exif::DURATION + self::DURATION => Exif::DURATION, + self::SUBLOCATION => Exif::SUBLOCATION, + self::CITY => Exif::CITY, + self::STATE => Exif::STATE, + self::COUNTRY => Exif::COUNTRY ); @@ -167,13 +175,16 @@ public function mapRawData(array $data) // manipulate the value if necessary switch ($field) { case self::DATETIMEORIGINAL: + // Check if OffsetTimeOriginal (0x9011) is available try { - if(!(strtotime($value)==false)) { - $value = new DateTime(date('Y-m-d H:i:s', strtotime($value))); - } else { - continue 2; - } - } catch (Exception $exception) { + if (isset($data['UndefinedTag:0x9011'])) { + $timezone = new \DateTimeZone($data['UndefinedTag:0x9011']); + $value = new \DateTime($value, $timezone); + } else { + $value = new \DateTime($value); + } + } catch (\Exception $e) { + // Provided DateTimeOriginal or OffsetTimeOriginal invalid continue 2; } break; @@ -214,23 +225,23 @@ public function mapRawData(array $data) $value = $this->extractGPSCoordinate((array)$value, $GPSLongitudeRef); break; case self::GPSALTITUDE: - $flip = 1; - if(!(empty($data['GPSAltitudeRef'][0]))) { - $flip = ($data['GPSAltitudeRef'][0] == '1' || $data['GPSAltitudeRef'][0] == "\u{0001}") ? -1 : 1; + $flp = 1; + if (!(empty($data['GPSAltitudeRef'][0]))) { + $flp = ($data['GPSAltitudeRef'][0] == '1' || $data['GPSAltitudeRef'][0] == "\u{0001}") ? -1 : 1; } - $value = $flip * $this->normalizeComponent($value); + $value = $flp * $this->normalizeComponent($value); break; case self::IMGDIRECTION: $value = $this->normalizeComponent($value); break; case self::LENS_LR: if (!(empty($mappedData[Exif::LENS]))) { - $mappedData[Exif::LENS] = $value; + $mappedData[Exif::LENS] = $value; } break; case self::LENS_TYPE: if (!(empty($mappedData[Exif::LENS]))) { - $mappedData[Exif::LENS] = $value; + $mappedData[Exif::LENS] = $value; } break; } @@ -240,7 +251,7 @@ public function mapRawData(array $data) } // add GPS coordinates, if available - if (!(empty($mappedData[Exif::LATITUDE])) && !(empty($mappedData[Exif::LONGITUDE]))) { + if ((isset($mappedData[Exif::LATITUDE])) && (isset($mappedData[Exif::LONGITUDE]))) { $mappedData[Exif::GPS] = sprintf('%s,%s', $mappedData[Exif::LATITUDE], $mappedData[Exif::LONGITUDE]); } else { unset($mappedData[Exif::GPS]); @@ -294,14 +305,13 @@ protected function isFieldKnown(&$field) * @param string $ref * @return float */ - protected function extractGPSCoordinate(array $coordinate, string $ref) + protected function extractGPSCoordinate($coordinate, $ref) { - $degrees = count($coordinate) > 0 ? $this->normalizeComponent($coordinate[0]) : 0; - $minutes = count($coordinate) > 1 ? $this->normalizeComponent($coordinate[1]) : 0; - $seconds = count($coordinate) > 2 ? $this->normalizeComponent($coordinate[2]) : 0; - $flip = ($ref == 'W' || $ref == 'S') ? -1 : 1; - return $flip * ($degrees + (float) $minutes / 60 + (float) $seconds / 3600); + $minutes = count($coordinate) > 1 ? $this->normalizeComponent($coordinate[1]) : 0; + $seconds = count($coordinate) > 2 ? $this->normalizeComponent($coordinate[2]) : 0; + $flip = ($ref == 'W' || $ref == 'S') ? -1 : 1; + return $flip * ($degrees + (float) $minutes / 60 + (float) $seconds / 3600); } /** @@ -310,19 +320,16 @@ protected function extractGPSCoordinate(array $coordinate, string $ref) * @param string $component * @return float */ - protected function normalizeComponent(string $rational) + protected function normalizeComponent($rational) { $parts = explode('/', $rational, 2); - if (count($parts) <= 0) { - return 0.0; - } - if (count($parts) == 1) { - return (float) $parts[0]; - } - // case part[1] is 0, div by 0 is forbidden. - if ($parts[1] == 0) { - return (float) 0; - } - return (float) $parts[0] / $parts[1]; + if (count($parts) == 1) { + return (float) $parts[0]; + } + // case part[1] is 0, div by 0 is forbidden. + if ($parts[1] == 0) { + return (float) 0; + } + return (float) $parts[0] / $parts[1]; } } diff --git a/lib/PHPExif/Reader/Reader.php b/lib/PHPExif/Reader/Reader.php index 34b7bb5..b6a322f 100755 --- a/lib/PHPExif/Reader/Reader.php +++ b/lib/PHPExif/Reader/Reader.php @@ -14,6 +14,7 @@ use PHPExif\Adapter\AdapterInterface; use PHPExif\Adapter\NoAdapterException; use PHPExif\Adapter\Exiftool as ExiftoolAdapter; +use PHPExif\Adapter\FFprobe as FFprobeAdapter; use PHPExif\Adapter\Native as NativeAdapter; /** @@ -29,6 +30,7 @@ class Reader implements ReaderInterface { const TYPE_NATIVE = 'native'; const TYPE_EXIFTOOL = 'exiftool'; + const TYPE_FFPROBE = 'ffprobe'; /** * The current adapter @@ -79,6 +81,9 @@ public static function factory($type) case self::TYPE_EXIFTOOL: $adapter = new ExiftoolAdapter(); break; + case self::TYPE_FFPROBE: + $adapter = new FFProbeAdapter(); + break; default: throw new \InvalidArgumentException( sprintf('Unknown type "%1$s"', $type) diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 7470936..dc60816 100755 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -22,9 +22,6 @@ diff --git a/tests/PHPExif/Adapter/AdapterAbstractTest.php b/tests/PHPExif/Adapter/AdapterAbstractTest.php index 46982da..0a5d246 100644 --- a/tests/PHPExif/Adapter/AdapterAbstractTest.php +++ b/tests/PHPExif/Adapter/AdapterAbstractTest.php @@ -1,16 +1,15 @@ - * @covers \PHPExif\Adapter\AdapterInterface */ -class AdapterAbstractTest extends \PHPUnit_Framework_TestCase +class AdapterAbstractTest extends PHPUnit\Framework\TestCase { /** * @var \PHPExif\Adapter\Exiftool|\PHPExif\Adapter\Native */ protected $adapter; - public function setUp() + protected function setUp(): void { $this->adapter = new \PHPExif\Adapter\Native(); } @@ -229,4 +228,3 @@ public function testGetHydratorLazyLoadingSetsInProperty() $this->assertInstanceOf($hydratorClass, $reflProp->getValue($this->adapter)); } } - diff --git a/tests/PHPExif/Adapter/ExiftoolProcOpenTest.php b/tests/PHPExif/Adapter/ExiftoolProcOpenTest.php index 9d9bd39..81bc457 100644 --- a/tests/PHPExif/Adapter/ExiftoolProcOpenTest.php +++ b/tests/PHPExif/Adapter/ExiftoolProcOpenTest.php @@ -1,9 +1,9 @@ */ - class ExiftoolProcOpenTest extends \PHPUnit_Framework_TestCase + class ExiftoolProcOpenTest extends \PHPUnit\Framework\TestCase { /** * @var \PHPExif\Adapter\Exiftool */ protected $adapter; - public function setUp() + public function setUp() : void { global $mockProcOpen; $mockProcOpen = true; $this->adapter = new \PHPExif\Adapter\Exiftool(); } - public function tearDown() + public function tearDown() : void { global $mockProcOpen; $mockProcOpen = false; @@ -49,10 +49,10 @@ public function tearDown() /** * @group exiftool * @covers \PHPExif\Adapter\Exiftool::getCliOutput - * @expectedException RuntimeException */ public function testGetCliOutput() { + $this->expectException('RuntimeException'); $reflMethod = new \ReflectionMethod('\PHPExif\Adapter\Exiftool', 'getCliOutput'); $reflMethod->setAccessible(true); diff --git a/tests/PHPExif/Adapter/ExiftoolTest.php b/tests/PHPExif/Adapter/ExiftoolTest.php index 49029e5..dc291a6 100644 --- a/tests/PHPExif/Adapter/ExiftoolTest.php +++ b/tests/PHPExif/Adapter/ExiftoolTest.php @@ -2,14 +2,14 @@ /** * @covers \PHPExif\Adapter\Exiftool:: */ -class ExiftoolTest extends \PHPUnit_Framework_TestCase +class ExiftoolTest extends \PHPUnit\Framework\TestCase { /** * @var \PHPExif\Adapter\Exiftool */ protected $adapter; - public function setUp() + public function setUp(): void { $this->adapter = new \PHPExif\Adapter\Exiftool(); } @@ -46,10 +46,10 @@ public function testSetToolPathInProperty() /** * @group exiftool * @covers \PHPExif\Adapter\Exiftool::setToolPath - * @expectedException InvalidArgumentException */ public function testSetToolPathThrowsException() { + $this->expectException('InvalidArgumentException'); $this->adapter->setToolPath('/foo/bar'); } @@ -60,7 +60,7 @@ public function testSetToolPathThrowsException() */ public function testGetToolPathLazyLoadsPath() { - $this->assertInternalType('string', $this->adapter->getToolPath()); + $this->assertIsString($this->adapter->getToolPath()); } /** @@ -105,7 +105,7 @@ public function testGetExifFromFile() $this->adapter->setOptions(array('encoding' => array('iptc' => 'cp1252'))); $result = $this->adapter->getExifFromFile($file); $this->assertInstanceOf('\PHPExif\Exif', $result); - $this->assertInternalType('array', $result->getRawData()); + $this->assertIsArray($result->getRawData()); $this->assertNotEmpty($result->getRawData()); } @@ -119,7 +119,7 @@ public function testGetExifFromFileWithUtf8() $this->adapter->setOptions(array('encoding' => array('iptc' => 'utf8'))); $result = $this->adapter->getExifFromFile($file); $this->assertInstanceOf('\PHPExif\Exif', $result); - $this->assertInternalType('array', $result->getRawData()); + $this->assertIsArray($result->getRawData()); $this->assertNotEmpty($result->getRawData()); } @@ -140,6 +140,6 @@ public function testGetCliOutput() ) ); - $this->assertInternalType('string', $result); + $this->assertIsString($result); } } diff --git a/tests/PHPExif/Adapter/FFprobeTest.php b/tests/PHPExif/Adapter/FFprobeTest.php new file mode 100755 index 0000000..ff192db --- /dev/null +++ b/tests/PHPExif/Adapter/FFprobeTest.php @@ -0,0 +1,49 @@ + + */ +class FFprobeTest extends \PHPUnit\Framework\TestCase +{ + /** + * @var \PHPExif\Adapter\FFprobe + */ + protected $adapter; + + public function setUp(): void + { + $this->adapter = new \PHPExif\Adapter\FFprobe(); + } + + /** + * @group native + * @covers \PHPExif\Adapter\FFprobe::getExifFromFile + */ + public function testGetExifFromFileHasData() + { + $file = PHPEXIF_TEST_ROOT . '/files/IMG_3824.MOV'; + $result = $this->adapter->getExifFromFile($file); + $this->assertInstanceOf('\PHPExif\Exif', $result); + $this->assertIsArray($result->getRawData()); + $this->assertNotEmpty($result->getRawData()); + + $file = PHPEXIF_TEST_ROOT . '/files/IMG_3825.MOV'; + $result = $this->adapter->getExifFromFile($file); + $this->assertInstanceOf('\PHPExif\Exif', $result); + $this->assertIsArray($result->getRawData()); + $this->assertNotEmpty($result->getRawData()); + } + + /** + * @group native + * @covers \PHPExif\Adapter\FFprobe::getExifFromFile + */ + public function testErrorImageUsed() + { + $file = PHPEXIF_TEST_ROOT . '/files/morning_glory_pool_500.jpg';; + $result = $this->adapter->getExifFromFile($file); + $this->assertIsBool($result); + $this->assertEquals(false, $result); + } + + +} diff --git a/tests/PHPExif/Adapter/NativeTest.php b/tests/PHPExif/Adapter/NativeTest.php index 0df554e..87a0bf4 100755 --- a/tests/PHPExif/Adapter/NativeTest.php +++ b/tests/PHPExif/Adapter/NativeTest.php @@ -2,14 +2,14 @@ /** * @covers \PHPExif\Adapter\Native:: */ -class NativeTest extends \PHPUnit_Framework_TestCase +class NativeTest extends \PHPUnit\Framework\TestCase { /** * @var \PHPExif\Adapter\Native */ protected $adapter; - public function setUp() + public function setUp(): void { $this->adapter = new \PHPExif\Adapter\Native(); } @@ -120,7 +120,7 @@ public function testGetExifFromFileHasData() $file = PHPEXIF_TEST_ROOT . '/files/morning_glory_pool_500.jpg'; $result = $this->adapter->getExifFromFile($file); $this->assertInstanceOf('\PHPExif\Exif', $result); - $this->assertInternalType('array', $result->getRawData()); + $this->assertIsArray($result->getRawData()); $this->assertNotEmpty($result->getRawData()); } diff --git a/tests/PHPExif/ExifTest.php b/tests/PHPExif/ExifTest.php index 82881d2..ba6e57b 100755 --- a/tests/PHPExif/ExifTest.php +++ b/tests/PHPExif/ExifTest.php @@ -2,7 +2,7 @@ /** * @covers \PHPExif\Exif:: */ -class ExifTest extends \PHPUnit_Framework_TestCase +class ExifTest extends \PHPUnit\Framework\TestCase { /** * @var \PHPExif\Exif @@ -12,7 +12,7 @@ class ExifTest extends \PHPUnit_Framework_TestCase /** * Setup function before the tests */ - public function setUp() + public function setUp(): void { $this->exif = new \PHPExif\Exif(); } @@ -125,10 +125,26 @@ public function testSetData() * @covers \PHPExif\Exif::getJobtitle * @covers \PHPExif\Exif::getMimeType * @covers \PHPExif\Exif::getFileSize + * @covers \PHPExif\Exif::getFileName * @covers \PHPExif\Exif::getHeadline * @covers \PHPExif\Exif::getColorSpace * @covers \PHPExif\Exif::getOrientation * @covers \PHPExif\Exif::getGPS + * @covers \PHPExif\Exif::getDescription + * @covers \PHPExif\Exif::getMake + * @covers \PHPExif\Exif::getAltitude + * @covers \PHPExif\Exif::getLatitude + * @covers \PHPExif\Exif::getLongitude + * @covers \PHPExif\Exif::getImgDirection + * @covers \PHPExif\Exif::getLens + * @covers \PHPExif\Exif::getContentIdentifier + * @covers \PHPExif\Exif::getFramerate + * @covers \PHPExif\Exif::getDuration + * @covers \PHPExif\Exif::getMicroVideoOffset + * @covers \PHPExif\Exif::getCity + * @covers \PHPExif\Exif::getSublocation + * @covers \PHPExif\Exif::getState + * @covers \PHPExif\Exif::getCountry * @param string $accessor */ public function testUndefinedPropertiesReturnFalse($accessor) @@ -169,10 +185,26 @@ public function providerUndefinedPropertiesReturnFalse() array('getJobtitle'), array('getMimeType'), array('getFileSize'), + array('getFileName'), array('getHeadline'), array('getColorSpace'), array('getOrientation'), array('getGPS'), + array('getDescription'), + array('getMake'), + array('getAltitude'), + array('getLatitude'), + array('getLongitude'), + array('getImgDirection'), + array('getLens'), + array('getContentIdentifier'), + array('getFramerate'), + array('getDuration'), + array('getMicroVideoOffset'), + array('getCity'), + array('getSublocation'), + array('getState'), + array('getCountry'), ); } @@ -487,6 +519,18 @@ public function testGetFileSize() $this->assertEquals($expected, $this->exif->getFileSize()); } + /** + * @group exif + * @covers \PHPExif\Exif::getFileName + */ + public function testGetFileName() + { + $expected = '27852365.jpg'; + $data[\PHPExif\Exif::FILENAME] = $expected; + $this->exif->setData($data); + $this->assertEquals($expected, $this->exif->getFileName()); + } + /** * @group exif * @covers \PHPExif\Exif::getOrientation @@ -511,6 +555,186 @@ public function testGetGPS() $this->assertEquals($expected, $this->exif->getGPS()); } + /** + * @group exif + * @covers \PHPExif\Exif::getDescription + */ + public function testGetDescription() + { + $expected = 'Lorem ipsum'; + $data[\PHPExif\Exif::DESCRIPTION] = $expected; + $this->exif->setData($data); + $this->assertEquals($expected, $this->exif->getDescription()); + } + + /** + * @group exif + * @covers \PHPExif\Exif::getMake + */ + public function testGetMake() + { + $expected = 'Make'; + $data[\PHPExif\Exif::MAKE] = $expected; + $this->exif->setData($data); + $this->assertEquals($expected, $this->exif->getMake()); + } + + /** + * @group exif + * @covers \PHPExif\Exif::getAltitude + */ + public function testGetAltitude() + { + $expected = '8848'; + $data[\PHPExif\Exif::ALTITUDE] = $expected; + $this->exif->setData($data); + $this->assertEquals($expected, $this->exif->getAltitude()); + } + + /** + * @group exif + * @covers \PHPExif\Exif::getLatitude + */ + public function testGetLatitude() + { + $expected = '40.333452380556'; + $data[\PHPExif\Exif::LATITUDE] = $expected; + $this->exif->setData($data); + $this->assertEquals($expected, $this->exif->getLatitude()); + } + + /** + * @group exif + * @covers \PHPExif\Exif::getLongitude + */ + public function testGetLongitude() + { + $expected = '-20.167314813889'; + $data[\PHPExif\Exif::LONGITUDE] = $expected; + $this->exif->setData($data); + $this->assertEquals($expected, $this->exif->getLongitude()); + } + + /** + * @group exif + * @covers \PHPExif\Exif::getImgDirection + */ + public function testGetImgDirection() + { + $expected = '180'; + $data[\PHPExif\Exif::IMGDIRECTION] = $expected; + $this->exif->setData($data); + $this->assertEquals($expected, $this->exif->getImgDirection()); + } + + /** + * @group exif + * @covers \PHPExif\Exif::getLens + */ + public function testGetLens() + { + $expected = '70 - 200mm'; + $data[\PHPExif\Exif::LENS] = $expected; + $this->exif->setData($data); + $this->assertEquals($expected, $this->exif->getLens()); + } + + /** + * @group exif + * @covers \PHPExif\Exif::getContentIdentifier + */ + public function testGetContentIdentifier() + { + $expected = 'C09DCB26-D321-4254-9F68-2E2E7FA16155'; + $data[\PHPExif\Exif::CONTENTIDENTIFIER] = $expected; + $this->exif->setData($data); + $this->assertEquals($expected, $this->exif->getContentIdentifier()); + } + + /** + * @group exif + * @covers \PHPExif\Exif::getFramerate + */ + public function testGetFramerate() + { + $expected = '24'; + $data[\PHPExif\Exif::FRAMERATE] = $expected; + $this->exif->setData($data); + $this->assertEquals($expected, $this->exif->getFramerate()); + } + + /** + * @group exif + * @covers \PHPExif\Exif::getDuration + */ + public function testGetDuration() + { + $expected = '1s'; + $data[\PHPExif\Exif::DURATION] = $expected; + $this->exif->setData($data); + $this->assertEquals($expected, $this->exif->getDuration()); + } + + /** + * @group exif + * @covers \PHPExif\Exif::getMicroVideoOffset + */ + public function testGetMicroVideoOffset() + { + $expected = '3062730'; + $data[\PHPExif\Exif::MICROVIDEOOFFSET] = $expected; + $this->exif->setData($data); + $this->assertEquals($expected, $this->exif->getMicroVideoOffset()); + } + + /** + * @group exif + * @covers \PHPExif\Exif::getCity + */ + public function testGetCity() + { + $expected = 'New York'; + $data[\PHPExif\Exif::CITY] = $expected; + $this->exif->setData($data); + $this->assertEquals($expected, $this->exif->getCity()); + } + + /** + * @group exif + * @covers \PHPExif\Exif::getSublocation + */ + public function testGetSublocation() + { + $expected = 'sublocation'; + $data[\PHPExif\Exif::SUBLOCATION] = $expected; + $this->exif->setData($data); + $this->assertEquals($expected, $this->exif->getSublocation()); + } + + /** + * @group exif + * @covers \PHPExif\Exif::getState + */ + public function testGetState() + { + $expected = 'New York'; + $data[\PHPExif\Exif::STATE] = $expected; + $this->exif->setData($data); + $this->assertEquals($expected, $this->exif->getState()); + } + + /** + * @group exif + * @covers \PHPExif\Exif::getCountry + */ + public function testGetCountry() + { + $expected = 'USA'; + $data[\PHPExif\Exif::COUNTRY] = $expected; + $this->exif->setData($data); + $this->assertEquals($expected, $this->exif->getCountry()); + } + /** * @group exif * @covers \PHPExif\Exif::setAperture @@ -535,10 +759,26 @@ public function testGetGPS() * @covers \PHPExif\Exif::setJobtitle * @covers \PHPExif\Exif::setMimeType * @covers \PHPExif\Exif::setFileSize + * @covers \PHPExif\Exif::setFileName * @covers \PHPExif\Exif::setHeadline * @covers \PHPExif\Exif::setColorSpace * @covers \PHPExif\Exif::setOrientation * @covers \PHPExif\Exif::setGPS + * @covers \PHPExif\Exif::setDescription + * @covers \PHPExif\Exif::setMake + * @covers \PHPExif\Exif::setAltitude + * @covers \PHPExif\Exif::setLongitude + * @covers \PHPExif\Exif::setLatitude + * @covers \PHPExif\Exif::setImgDirection + * @covers \PHPExif\Exif::setLens + * @covers \PHPExif\Exif::setContentIdentifier + * @covers \PHPExif\Exif::setFramerate + * @covers \PHPExif\Exif::setDuration + * @covers \PHPExif\Exif::setMicroVideoOffset + * @covers \PHPExif\Exif::setCity + * @covers \PHPExif\Exif::setSublocation + * @covers \PHPExif\Exif::setState + * @covers \PHPExif\Exif::setCountry */ public function testMutatorMethodsSetInProperty() { @@ -637,4 +877,3 @@ public function testAdapterConsistency() } } } - diff --git a/tests/PHPExif/Hydrator/MutatorTest.php b/tests/PHPExif/Hydrator/MutatorTest.php index 1bf40f6..6e05ff2 100644 --- a/tests/PHPExif/Hydrator/MutatorTest.php +++ b/tests/PHPExif/Hydrator/MutatorTest.php @@ -1,14 +1,13 @@ - * @covers \PHPExif\Hydrator\HydratorInterface */ -class MutatorTest extends \PHPUnit_Framework_TestCase +class MutatorTest extends \PHPUnit\Framework\TestCase { /** * Setup function before the tests */ - public function setUp() + protected function setUp(): void { } @@ -70,4 +69,3 @@ public function setBar() { } } - diff --git a/tests/PHPExif/Mapper/ExiftoolMapperTest.php b/tests/PHPExif/Mapper/ExiftoolMapperTest.php index e206be5..9da76f9 100644 --- a/tests/PHPExif/Mapper/ExiftoolMapperTest.php +++ b/tests/PHPExif/Mapper/ExiftoolMapperTest.php @@ -2,11 +2,11 @@ /** * @covers \PHPExif\Mapper\Exiftool:: */ -class ExiftoolMapperTest extends \PHPUnit_Framework_TestCase +class ExiftoolMapperTest extends \PHPUnit\Framework\TestCase { protected $mapper; - public function setUp() + public function setUp(): void { $this->mapper = new \PHPExif\Mapper\Exiftool; } @@ -49,6 +49,27 @@ public function testMapRawDataMapsFieldsCorrectly() unset($map[\PHPExif\Mapper\Exiftool::FOCALLENGTH]); unset($map[\PHPExif\Mapper\Exiftool::GPSLATITUDE]); unset($map[\PHPExif\Mapper\Exiftool::GPSLONGITUDE]); + unset($map[\PHPExif\Mapper\Exiftool::CAPTION]); + unset($map[\PHPExif\Mapper\Exiftool::CONTENTIDENTIFIER]); + unset($map[\PHPExif\Mapper\Exiftool::KEYWORDS]); + unset($map[\PHPExif\Mapper\Exiftool::DATETIMEORIGINAL]); + unset($map[\PHPExif\Mapper\Exiftool::DATETIMEORIGINAL_QUICKTIME]); + unset($map[\PHPExif\Mapper\Exiftool::MAKE_QUICKTIME]); + unset($map[\PHPExif\Mapper\Exiftool::MODEL_QUICKTIME]); + unset($map[\PHPExif\Mapper\Exiftool::FRAMERATE]); + unset($map[\PHPExif\Mapper\Exiftool::FRAMERATE_QUICKTIME_1]); + unset($map[\PHPExif\Mapper\Exiftool::FRAMERATE_QUICKTIME_2]); + unset($map[\PHPExif\Mapper\Exiftool::FRAMERATE_QUICKTIME_3]); + unset($map[\PHPExif\Mapper\Exiftool::DURATION]); + unset($map[\PHPExif\Mapper\Exiftool::DURATION_QUICKTIME]); + unset($map[\PHPExif\Mapper\Exiftool::GPSLATITUDE_QUICKTIME]); + unset($map[\PHPExif\Mapper\Exiftool::GPSLONGITUDE_QUICKTIME]); + unset($map[\PHPExif\Mapper\Exiftool::GPSALTITUDE_QUICKTIME]); + unset($map[\PHPExif\Mapper\Exiftool::MICROVIDEOOFFSET]); + unset($map[\PHPExif\Mapper\Exiftool::CITY]); + unset($map[\PHPExif\Mapper\Exiftool::SUBLOCATION]); + unset($map[\PHPExif\Mapper\Exiftool::STATE]); + unset($map[\PHPExif\Mapper\Exiftool::COUNTRY]); // create raw data $keys = array_keys($map); @@ -60,7 +81,7 @@ public function testMapRawDataMapsFieldsCorrectly() $mapped = $this->mapper->mapRawData($rawData); $i = 0; - foreach ($mapped as $key => $value) { + foreach ($mapped as $key => $value) { $this->assertEquals($map[$keys[$i]], $key); $i++; } @@ -116,6 +137,77 @@ public function testMapRawDataCorrectlyFormatsCreationDate() ); } + /** + * @group mapper + * @covers \PHPExif\Mapper\Exiftool::mapRawData + */ + public function testMapRawDataCorrectlyFormatsCreationDateWithTimeZone() + { + $data = array ( + array( + \PHPExif\Mapper\Exiftool::DATETIMEORIGINAL => '2015:04:01 12:11:09+0200', + ), + array( + \PHPExif\Mapper\Exiftool::DATETIMEORIGINAL => '2015:04:01 12:11:09', + 'ExifIFD:OffsetTimeOriginal' => '+0200', + ), + array( + \PHPExif\Mapper\Exiftool::DATETIMEORIGINAL_QUICKTIME => '2015-04-01T12:11:09+0200', + \PHPExif\Mapper\Exiftool::DATETIMEORIGINAL => '2015:04:01 12:11:09', + 'ExifIFD:OffsetTimeOriginal' => '+0200', + ) + ); + + foreach ($data as $key => $rawData) { + $mapped = $this->mapper->mapRawData($rawData); + + $result = reset($mapped); + $this->assertInstanceOf('\\DateTime', $result); + $this->assertEquals( + '2015:04:01 12:11:09', + $result->format('Y:m:d H:i:s') + ); + $this->assertEquals( + 7200, + $result->getOffset() + ); + $this->assertEquals( + '+02:00', + $result->getTimezone()->getName() + ); + } + + } + + /** + * @group mapper + * @covers \PHPExif\Mapper\Exiftool::mapRawData + */ + public function testMapRawDataCorrectlyFormatsCreationDateWithTimeZone2() + { + $rawData = array( + \PHPExif\Mapper\Exiftool::DATETIMEORIGINAL => '2015:04:01 12:11:09', + 'ExifIFD:OffsetTimeOriginal' => '+0200', + ); + + $mapped = $this->mapper->mapRawData($rawData); + + $result = reset($mapped); + $this->assertInstanceOf('\\DateTime', $result); + $this->assertEquals( + '2015:04:01 12:11:09', + $result->format('Y:m:d H:i:s') + ); + $this->assertEquals( + 7200, + $result->getOffset() + ); + $this->assertEquals( + '+02:00', + $result->getTimezone()->getName() + ); + } + /** * @group mapper * @covers \PHPExif\Mapper\Exiftool::mapRawData @@ -131,6 +223,21 @@ public function testMapRawDataCorrectlyIgnoresIncorrectCreationDate() $this->assertEquals(false, reset($mapped)); } + /** + * @group mapper + * @covers \PHPExif\Mapper\Exiftool::mapRawData + */ + public function testMapRawDataCorrectlyIgnoresIncorrectCreationDate2() + { + $rawData = array( + \PHPExif\Mapper\Exiftool::DATETIMEORIGINAL_QUICKTIME => '2015:04:01', + ); + + $mapped = $this->mapper->mapRawData($rawData); + + $this->assertEquals(false, reset($mapped)); + } + /** * @group mapper * @covers \PHPExif\Mapper\Exiftool::mapRawData @@ -197,20 +304,21 @@ public function testMapRawDataCorrectlyFormatsGPSData() * @group mapper * @covers \PHPExif\Mapper\Exiftool::mapRawData */ - public function testMapRawDataCorrectlyFormatsNumericGPSData() + public function testMapRawDataIncorrectlyFormatedGPSData() { + $this->mapper->setNumeric(false); $result = $this->mapper->mapRawData( array( - \PHPExif\Mapper\Exiftool::GPSLATITUDE => '40.333452381', + \PHPExif\Mapper\Exiftool::GPSLATITUDE => '40 degrees 20\' 0.42857" N', 'GPS:GPSLatitudeRef' => 'North', - \PHPExif\Mapper\Exiftool::GPSLONGITUDE => '20.167314814', + \PHPExif\Mapper\Exiftool::GPSLONGITUDE => '20 degrees 10\' 2.33333" W', 'GPS:GPSLongitudeRef' => 'West', ) ); - $expected_gps = '40.333452381,-20.167314814'; - $expected_lat = '40.333452381'; - $expected_lon = '-20.167314814'; + $expected_gps = false; + $expected_lat = false; + $expected_lon = false; $this->assertCount(3, $result); $this->assertEquals($expected_gps, $result['gps']); $this->assertEquals($expected_lat, $result['latitude']); @@ -221,9 +329,8 @@ public function testMapRawDataCorrectlyFormatsNumericGPSData() * @group mapper * @covers \PHPExif\Mapper\Exiftool::mapRawData */ - public function testMapRawDataCorrectlyIgnoresIncorrectGPSData() + public function testMapRawDataCorrectlyFormatsNumericGPSData() { - $this->mapper->setNumeric(false); $result = $this->mapper->mapRawData( array( \PHPExif\Mapper\Exiftool::GPSLATITUDE => '40.333452381', @@ -233,7 +340,13 @@ public function testMapRawDataCorrectlyIgnoresIncorrectGPSData() ) ); - $this->assertCount(0, $result); + $expected_gps = '40.333452381,-20.167314814'; + $expected_lat = '40.333452381'; + $expected_lon = '-20.167314814'; + $this->assertCount(3, $result); + $this->assertEquals($expected_gps, $result['gps']); + $this->assertEquals($expected_lat, $result['latitude']); + $this->assertEquals($expected_lon, $result['longitude']); } /** @@ -301,4 +414,157 @@ public function testMapRawDataCorrectlyIgnoresInvalidCreateDate() $result ); } + + /** + * @group mapper + * @covers \PHPExif\Mapper\Exiftool::mapRawData + */ + public function testMapRawDataCorrectlyAltitude() + { + $result = $this->mapper->mapRawData( + array( + \PHPExif\Mapper\Exiftool::GPSALTITUDE => '122.053', + 'GPS:GPSAltitudeRef' => '0', + ) + ); + $expected = 122.053; + $this->assertEquals($expected, reset($result)); + } + + /** + * @group mapper + * @covers \PHPExif\Mapper\Exiftool::mapRawData + */ + public function testMapRawDataCorrectlyNegativeAltitude() + { + $result = $this->mapper->mapRawData( + array( + \PHPExif\Mapper\Exiftool::GPSALTITUDE => '122.053', + 'GPS:GPSAltitudeRef' => '1', + ) + ); + $expected = '-122.053'; + $this->assertEquals($expected, reset($result)); + } + + /** + * @group mapper + * @covers \PHPExif\Mapper\Exiftool::mapRawData + */ + public function testMapRawDataCorrectlyFormatsQuicktimeGPSData() + { + $result = $this->mapper->mapRawData( + array( + \PHPExif\Mapper\Exiftool::GPSLATITUDE_QUICKTIME => '40.333', + 'GPS:GPSLatitudeRef' => 'North', + \PHPExif\Mapper\Exiftool::GPSLONGITUDE_QUICKTIME => '-20.167', + 'GPS:GPSLongitudeRef' => 'West', + ) + ); + $expected_gps = '40.333,-20.167'; + $expected_lat = '40.333'; + $expected_lon = '-20.167'; + $this->assertCount(3, $result); + $this->assertEquals($expected_gps, $result['gps']); + $this->assertEquals($expected_lat, $result['latitude']); + $this->assertEquals($expected_lon, $result['longitude']); + } + + /** + * @group mapper + * @covers \PHPExif\Mapper\Exiftool::mapRawData + */ + public function testMapRawDataCorrectlyQuicktimeAltitude() + { + $result = $this->mapper->mapRawData( + array( + \PHPExif\Mapper\Exiftool::GPSALTITUDE_QUICKTIME => '122.053', + 'Composite:GPSAltitudeRef' => '1', + ) + ); + $expected = -122.053; + $this->assertEquals($expected, reset($result)); + } + + /** + * @group mapper + * @covers \PHPExif\Mapper\Exiftool::mapRawData + */ + public function testMapRawDataCorrectlyHeightVideo() + { + + $rawData = array( + '600' => array( + \PHPExif\Mapper\Exiftool::IMAGEHEIGHT_VIDEO => '800x600', + ), + '600' => array( + \PHPExif\Mapper\Exiftool::IMAGEHEIGHT_VIDEO => '800x600', + 'Composite:Rotation' => '0', + ), + '800' => array( + \PHPExif\Mapper\Exiftool::IMAGEHEIGHT_VIDEO => '800x600', + 'Composite:Rotation' => '90', + ), + '800' => array( + \PHPExif\Mapper\Exiftool::IMAGEHEIGHT_VIDEO => '800x600', + 'Composite:Rotation' => '270', + ), + '600' => array( + \PHPExif\Mapper\Exiftool::IMAGEHEIGHT_VIDEO => '800x600', + 'Composite:Rotation' => '360', + ), + '600' => array( + \PHPExif\Mapper\Exiftool::IMAGEHEIGHT_VIDEO => '800x600', + 'Composite:Rotation' => '180', + ), + ); + + foreach ($rawData as $expected => $value) { + $mapped = $this->mapper->mapRawData($value); + + $this->assertEquals($expected, $mapped['height']); + } + } + + + + /** + * @group mapper + * @covers \PHPExif\Mapper\Exiftool::mapRawData + */ + public function testMapRawDataCorrectlyWidthVideo() + { + + $rawData = array( + '800' => array( + \PHPExif\Mapper\Exiftool::IMAGEWIDTH_VIDEO => '800x600', + ), + '800' => array( + \PHPExif\Mapper\Exiftool::IMAGEWIDTH_VIDEO => '800x600', + 'Composite:Rotation' => '0', + ), + '600' => array( + \PHPExif\Mapper\Exiftool::IMAGEWIDTH_VIDEO => '800x600', + 'Composite:Rotation' => '90', + ), + '600' => array( + \PHPExif\Mapper\Exiftool::IMAGEWIDTH_VIDEO => '800x600', + 'Composite:Rotation' => '270', + ), + '800' => array( + \PHPExif\Mapper\Exiftool::IMAGEWIDTH_VIDEO => '800x600', + 'Composite:Rotation' => '360', + ), + '800' => array( + \PHPExif\Mapper\Exiftool::IMAGEWIDTH_VIDEO => '800x600', + 'Composite:Rotation' => '180', + ), + ); + + foreach ($rawData as $expected => $value) { + $mapped = $this->mapper->mapRawData($value); + + $this->assertEquals($expected, $mapped['width']); + } + } } diff --git a/tests/PHPExif/Mapper/FFprobeMapperTest.php b/tests/PHPExif/Mapper/FFprobeMapperTest.php new file mode 100644 index 0000000..72a2015 --- /dev/null +++ b/tests/PHPExif/Mapper/FFprobeMapperTest.php @@ -0,0 +1,494 @@ + + */ +class FFprobeMapperTest extends \PHPUnit\Framework\TestCase +{ + protected $mapper; + + public function setUp(): void + { + $this->mapper = new \PHPExif\Mapper\FFprobe; + } + + /** + * @group mapper + */ + public function testClassImplementsCorrectInterface() + { + $this->assertInstanceOf('\\PHPExif\\Mapper\\MapperInterface', $this->mapper); + } + + /** + * @group mapper + * @covers \PHPExif\Mapper\FFprobe::mapRawData + */ + public function testMapRawDataIgnoresFieldIfItDoesntExist() + { + $rawData = array('foo' => 'bar'); + $mapped = $this->mapper->mapRawData($rawData); + + $this->assertCount(0, $mapped); + } + + /** + * @group mapper + * @covers \PHPExif\Mapper\FFprobe::mapRawData + */ + public function testMapRawDataMapsFieldsCorrectly() + { + $reflProp = new \ReflectionProperty(get_class($this->mapper), 'map'); + $reflProp->setAccessible(true); + $map = $reflProp->getValue($this->mapper); + + // ignore custom formatted data stuff: + unset($map[\PHPExif\Mapper\FFprobe::FILESIZE]); + unset($map[\PHPExif\Mapper\FFprobe::FILENAME]); + unset($map[\PHPExif\Mapper\FFprobe::MIMETYPE]); + unset($map[\PHPExif\Mapper\FFprobe::GPSLATITUDE]); + unset($map[\PHPExif\Mapper\FFprobe::GPSLONGITUDE]); + unset($map[\PHPExif\Mapper\FFprobe::QUICKTIME_GPSALTITUDE]); + unset($map[\PHPExif\Mapper\FFprobe::QUICKTIME_GPSLATITUDE]); + unset($map[\PHPExif\Mapper\FFprobe::QUICKTIME_GPSLONGITUDE]); + unset($map[\PHPExif\Mapper\FFprobe::QUICKTIME_DESCRIPTION]); + unset($map[\PHPExif\Mapper\FFprobe::QUICKTIME_MAKE]); + unset($map[\PHPExif\Mapper\FFprobe::QUICKTIME_MODEL]); + unset($map[\PHPExif\Mapper\FFprobe::QUICKTIME_CONTENTIDENTIFIER]); + unset($map[\PHPExif\Mapper\FFprobe::QUICKTIME_DESCRIPTION]); + unset($map[\PHPExif\Mapper\FFprobe::QUICKTIME_TITLE]); + unset($map[\PHPExif\Mapper\FFprobe::QUICKTIME_DATE]); + unset($map[\PHPExif\Mapper\FFprobe::QUICKTIME_KEYWORDS]); + unset($map[\PHPExif\Mapper\FFprobe::FRAMERATE]); + unset($map[\PHPExif\Mapper\FFprobe::DURATION]); + + // create raw data + $keys = array_keys($map); + $values = array(); + $values = array_pad($values, count($keys), 'foo'); + $rawData = array_combine($keys, $values); + + $mapped = $this->mapper->mapRawData($rawData); + + $i = 0; + foreach ($mapped as $key => $value) { + $this->assertEquals($map[$keys[$i]], $key); + $i++; + } + } + + /** + * @group mapper + * @covers \PHPExif\Mapper\FFprobe::mapRawData + */ + public function testMapRawDataCorrectlyFormatsDateTimeOriginal() + { + $rawData = array( + \PHPExif\Mapper\FFprobe::DATETIMEORIGINAL => '2015:04:01 12:11:09', + ); + + $mapped = $this->mapper->mapRawData($rawData); + + $result = reset($mapped); + $this->assertInstanceOf('\\DateTime', $result); + $this->assertEquals( + reset($rawData), + $result->format('Y:m:d H:i:s') + ); + } + + + /** + * @group mapper + * @covers \PHPExif\Mapper\FFprobe::mapRawData + */ + public function testMapRawDataCorrectlyFormatsCreationDateQuicktime() + { + $rawData = array( + \PHPExif\Mapper\FFprobe::QUICKTIME_DATE => '2015-04-01T12:11:09+0200', + \PHPExif\Mapper\FFprobe::DATETIMEORIGINAL => '2015-04-01T12:11:09.000000Z', + ); + + $mapped = $this->mapper->mapRawData($rawData); + + $result = reset($mapped); + $this->assertInstanceOf('\\DateTime', $result); + $this->assertEquals( + '2015:04:01 12:11:09', + $result->format('Y:m:d H:i:s') + ); + $this->assertEquals( + 7200, + $result->getOffset() + ); + $this->assertEquals( + '+02:00', + $result->getTimezone()->getName() + ); + } + + /** + * @group mapper + * @covers \PHPExif\Mapper\FFprobe::mapRawData + */ + public function testMapRawDataCorrectlyFormatsCreationDateWithTimeZone() + { + $rawData = array( + \PHPExif\Mapper\FFprobe::DATETIMEORIGINAL => '2015:04:01 12:11:09+0200', + ); + + $mapped = $this->mapper->mapRawData($rawData); + + $result = reset($mapped); + $this->assertInstanceOf('\\DateTime', $result); + $this->assertEquals( + '2015:04:01 12:11:09', + $result->format('Y:m:d H:i:s') + ); + $this->assertEquals( + 7200, + $result->getOffset() + ); + $this->assertEquals( + '+02:00', + $result->getTimezone()->getName() + ); + } + + /** + * @group mapper + * @covers \PHPExif\Mapper\FFprobe::mapRawData + */ + public function testMapRawDataCorrectlyIgnoresIncorrectDateTimeOriginal() + { + $rawData = array( + \PHPExif\Mapper\FFprobe::DATETIMEORIGINAL => '2015:04:01', + ); + + $mapped = $this->mapper->mapRawData($rawData); + + $this->assertEquals(false, reset($mapped)); + } + + /** + * @group mapper + * @covers \PHPExif\Mapper\FFprobe::mapRawData + */ + public function testMapRawDataCorrectlyIgnoresIncorrectDateTimeOriginal2() + { + $rawData = array( + \PHPExif\Mapper\FFprobe::QUICKTIME_DATE => '2015:04:01', + ); + + $mapped = $this->mapper->mapRawData($rawData); + + $this->assertEquals(false, reset($mapped)); + } + + /** + * @group mapper + * @covers \PHPExif\Mapper\FFprobe::mapRawData + */ + public function testMapRawDataCorrectlyFormatsQuickTimeGPSData() + { + $expected = array( + '+27.5916+86.5640+8850/' => array( + \PHPExif\Exif::LATITUDE => '27.5916', + \PHPExif\Exif::LONGITUDE => '86.5640', + \PHPExif\Exif::ALTITUDE => '8850', + ), + ); + + + foreach ($expected as $key => $value) { + $result = $this->mapper->mapRawData(array('com.apple.quicktime.location.ISO6709' => $key)); + + $this->assertEquals($value[\PHPExif\Exif::LATITUDE], $result[\PHPExif\Exif::LATITUDE]); + $this->assertEquals($value[\PHPExif\Exif::LONGITUDE], $result[\PHPExif\Exif::LONGITUDE]); + $this->assertEquals($value[\PHPExif\Exif::ALTITUDE], $result[\PHPExif\Exif::ALTITUDE]); + } + } + + /** + * @group mapper + * @covers \PHPExif\Mapper\FFprobe::mapRawData + */ + public function testMapRawDataCorrectlyRotatesDimensions() + { + $expected = array( + '600' => array( + 'tags' => array('rotate' => '90'), + 'width' => '800', + 'height' => '600', + ), + ); + + + foreach ($expected as $key => $value) { + $result = $this->mapper->mapRawData($value); + + $this->assertEquals($key, $result[\PHPExif\Exif::WIDTH]); + } + } + + /** + * @group mapper + * @covers \PHPExif\Mapper\FFprobe::mapRawData + */ + public function testMapRawDataCorrectlyFormatsGPSData() + { + $expected = array( + '+40.333452380952,+20.167314814815' => array( + 'location' => '+40.333452380952+20.167314814815/', + ), + '+0,+0' => array( + 'location' => '+0+0/', + ), + '+71.706936,-42.604303' => array( + 'location' => '+71.706936-42.604303/', + ), + ); + + foreach ($expected as $key => $value) { + $result = $this->mapper->mapRawData($value); + $this->assertEquals($key, $result[\PHPExif\Exif::GPS]); + } + } + + /** + * @group mapper + * @covers \PHPExif\Mapper\FFprobe::mapRawData + */ + public function testMapRawDataCorrectlyFramerate() + { + $expected = array( + '30' => array( + 'avg_frame_rate' => '30', + ), + '20' => array( + 'avg_frame_rate' => '200/10', + ) + ); + + foreach ($expected as $key => $value) { + $result = $this->mapper->mapRawData($value); + $this->assertEquals($key, reset($result)); + } + } + + public function testMapRawDataCorrectlyFormatsDifferentDateTimeString() + { + $rawData = array( + \PHPExif\Mapper\FFprobe::DATETIMEORIGINAL => '2014-12-15 00:12:00' + ); + + $mapped = $this->mapper->mapRawData( + $rawData + ); + + $result = reset($mapped); + $this->assertInstanceOf('\DateTime', $result); + $this->assertEquals( + reset($rawData), + $result->format("Y-m-d H:i:s") + ); + } + + public function testMapRawDataCorrectlyIgnoresInvalidCreateDate() + { + $rawData = array( + \PHPExif\Mapper\FFprobe::DATETIMEORIGINAL => 'Invalid Date String' + ); + + $result = $this->mapper->mapRawData( + $rawData + ); + + $this->assertCount(0, $result); + $this->assertNotEquals( + reset($rawData), + $result + ); + } + + /** + * @group mapper + * @covers \PHPExif\Mapper\FFprobe::normalizeComponent + */ + public function testNormalizeComponentCorrectly() + { + $reflMethod = new \ReflectionMethod('\PHPExif\Mapper\FFprobe', 'normalizeComponent'); + $reflMethod->setAccessible(true); + + $rawData = array( + '2/800' => 0.0025, + '1/400' => 0.0025, + '0/1' => 0, + '1/0' => 0, + '0' => 0, + ); + + foreach ($rawData as $value => $expected) { + $normalized = $reflMethod->invoke($this->mapper, $value); + + $this->assertEquals($expected, $normalized); + } + } + + /** + * @group mapper + * @covers \PHPExif\Mapper\Native::mapRawData + */ + public function testMapRawDataMatchesFieldsWithoutCaseSensibilityOnFirstLetter() + { + $rawData = array( + 'Width' => '800', + 'mimeType' => 'video/quicktime', + ); + $mapped = $this->mapper->mapRawData($rawData); + $this->assertCount(2, $mapped); + $keys = array_keys($mapped); + + $expected = array( + \PHPExif\Mapper\FFprobe::WIDTH, + \PHPExif\Mapper\FFprobe::MIMETYPE + ); + $this->assertEquals($expected, $keys); + } + + /** + * @group mapper + * @covers \PHPExif\Mapper\FFprobe::readISO6709 + */ + public function testreadISO6709() + { + $reflMethod = new \ReflectionMethod('\PHPExif\Mapper\FFprobe', 'readISO6709'); + $reflMethod->setAccessible(true); + + $testcase = array( + '+27.5916+086.5640+8850/' => array( + 'latitude' => '27.5916', + 'longitude' => '86.5640', + 'altitude' => '8850', + ), + '+1234.7-09854.1/' => array( + 'latitude' => '12.578333333333333', + 'longitude' => '-98.90166666666667', + 'altitude' => null, + ), + '+352139+1384339+3776/' => array( + 'latitude' => '35.36083333333333333333333333', + 'longitude' => '138.7275000000000000000000000', + 'altitude' => '3776', + ), + '+40.75-074.00/' => array( + 'latitude' => '40.75', + 'longitude' => '-74', + 'altitude' => null, + ), + '+123456.7-0985432.1/' => array( + 'latitude' => '12.58241666666666666666666667', + 'longitude' => '-98.90891666666666666666666667', + 'altitude' => null, + ), + '-90+000+2800/' => array( + 'latitude' => '-90', + 'longitude' => '0', + 'altitude' => '2800', + ), + '+35.658632+139.745411/' => array( + 'latitude' => '35.658632', + 'longitude' => '139.745411', + 'altitude' => null, + ), + '+48.8577+002.295/' => array( + 'latitude' => '48.8577', + 'longitude' => '2.295', + 'altitude' => null, + ), + '+48.8577+002.295-50/' => array( + 'latitude' => '48.8577', + 'longitude' => '2.295', + 'altitude' => '-50', + ), + ); + + foreach ($testcase as $key => $expected) { + $result = $reflMethod->invoke($this->mapper, $key); + $this->assertEquals($expected, $result); + } + } + + /** + * @group mapper + * @covers \PHPExif\Mapper\FFprobe::convertDMStoDecimal + */ + public function testconvertDMStoDecimal() + { + + $reflMethod = new \ReflectionMethod('\PHPExif\Mapper\FFprobe', 'convertDMStoDecimal'); + $reflMethod->setAccessible(true); + + $testcase = array( + '+27.5916' => array( + 'sign' => '+', + 'degrees' => '27', + 'minutes' => '', + 'seconds' => '', + 'fraction' => '.5916', + ), + '+86.5640' => array( + 'sign' => '+', + 'degrees' => '86', + 'minutes' => '', + 'seconds' => '', + 'fraction' => '.5640', + ), + '12.578333333333333' => array( + 'sign' => '+', + 'degrees' => '12', + 'minutes' => '34', + 'seconds' => '', + 'fraction' => '.7', + ), + '-98.90166666666666666666666667' => array( + 'sign' => '-', + 'degrees' => '098', + 'minutes' => '54', + 'seconds' => '', + 'fraction' => '.1', + ), + '+35.36083333333333333333333333' => array( + 'sign' => '+', + 'degrees' => '35', + 'minutes' => '21', + 'seconds' => '39', + 'fraction' => '', + ), + '+138.7275000000000000000000000' => array( + 'sign' => '+', + 'degrees' => '138', + 'minutes' => '43', + 'seconds' => '39', + 'fraction' => '', + ), + '12.58241666666666666666666667' => array( + 'sign' => '+', + 'degrees' => '12', + 'minutes' => '34', + 'seconds' => '56', + 'fraction' => '.7', + ), + '-98.90891666666666666666666667' => array( + 'sign' => '-', + 'degrees' => '098', + 'minutes' => '54', + 'seconds' => '32', + 'fraction' => '.1', + ), + ); + foreach ($testcase as $expected => $key) { + $result = $reflMethod->invoke($this->mapper, $key['sign'], $key['degrees'],$key['minutes'],$key['seconds'],$key['fraction']); + $this->assertEquals($expected, $result); + } + } +} diff --git a/tests/PHPExif/Mapper/NativeMapperTest.php b/tests/PHPExif/Mapper/NativeMapperTest.php index 4f7122d..ca19828 100644 --- a/tests/PHPExif/Mapper/NativeMapperTest.php +++ b/tests/PHPExif/Mapper/NativeMapperTest.php @@ -2,11 +2,11 @@ /** * @covers \PHPExif\Mapper\Native:: */ -class NativeMapperTest extends \PHPUnit_Framework_TestCase +class NativeMapperTest extends \PHPUnit\Framework\TestCase { protected $mapper; - public function setUp() + public function setUp(): void { $this->mapper = new \PHPExif\Mapper\Native; } @@ -49,6 +49,12 @@ public function testMapRawDataMapsFieldsCorrectly() unset($map[\PHPExif\Mapper\Native::YRESOLUTION]); unset($map[\PHPExif\Mapper\Native::GPSLATITUDE]); unset($map[\PHPExif\Mapper\Native::GPSLONGITUDE]); + unset($map[\PHPExif\Mapper\Native::FRAMERATE]); + unset($map[\PHPExif\Mapper\Native::DURATION]); + unset($map[\PHPExif\Mapper\Native::CITY]); + unset($map[\PHPExif\Mapper\Native::SUBLOCATION]); + unset($map[\PHPExif\Mapper\Native::STATE]); + unset($map[\PHPExif\Mapper\Native::COUNTRY]); // create raw data $keys = array_keys($map); @@ -86,6 +92,64 @@ public function testMapRawDataCorrectlyFormatsDateTimeOriginal() ); } + + /** + * @group mapper + * @covers \PHPExif\Mapper\Native::mapRawData + */ + public function testMapRawDataCorrectlyFormatsCreationDateWithTimeZone() + { + $rawData = array( + \PHPExif\Mapper\Native::DATETIMEORIGINAL => '2015:04:01 12:11:09+0200', + ); + + $mapped = $this->mapper->mapRawData($rawData); + + $result = reset($mapped); + $this->assertInstanceOf('\\DateTime', $result); + $this->assertEquals( + '2015:04:01 12:11:09', + $result->format('Y:m:d H:i:s') + ); + $this->assertEquals( + 7200, + $result->getOffset() + ); + $this->assertEquals( + '+02:00', + $result->getTimezone()->getName() + ); + } + + /** + * @group mapper + * @covers \PHPExif\Mapper\Native::mapRawData + */ + public function testMapRawDataCorrectlyFormatsCreationDateWithTimeZone2() + { + $rawData = array( + \PHPExif\Mapper\Native::DATETIMEORIGINAL => '2015:04:01 12:11:09', + 'UndefinedTag:0x9011' => '+0200', + ); + + $mapped = $this->mapper->mapRawData($rawData); + + $result = reset($mapped); + $this->assertInstanceOf('\\DateTime', $result); + $this->assertEquals( + '2015:04:01 12:11:09', + $result->format('Y:m:d H:i:s') + ); + $this->assertEquals( + 7200, + $result->getOffset() + ); + $this->assertEquals( + '+02:00', + $result->getTimezone()->getName() + ); + } + /** * @group mapper * @covers \PHPExif\Mapper\Native::mapRawData @@ -255,8 +319,31 @@ public function testMapRawDataCorrectlyFormatsGPSData() ); foreach ($expected as $key => $value) { - $result = $this->mapper->mapRawData($value); - $this->assertEquals($key, reset($result)); + $result = $this->mapper->mapRawData($value); + $this->assertEquals($key, $result[\PHPExif\Exif::GPS]); + } + } + + /** + * @group mapper + * @covers \PHPExif\Mapper\Native::mapRawData + */ + public function testMapRawDataCorrectlyFormatsAltitudeData() + { + $expected = array( + 8848.0 => array( + 'GPSAltitude' => '8848', + 'GPSAltitudeRef' => '0', + ), + -10994.0 => array( + 'GPSAltitude' => '10994', + 'GPSAltitudeRef' => '1', + ), + ); + + foreach ($expected as $key => $value) { + $result = $this->mapper->mapRawData($value); + $this->assertEquals($key, $result[\PHPExif\Exif::ALTITUDE]); } } diff --git a/tests/PHPExif/Reader/ReaderTest.php b/tests/PHPExif/Reader/ReaderTest.php index f84a0cf..177218c 100644 --- a/tests/PHPExif/Reader/ReaderTest.php +++ b/tests/PHPExif/Reader/ReaderTest.php @@ -1,10 +1,9 @@ - * @covers \PHPExif\Reader\ReaderInterface * @covers \PHPExif\Adapter\NoAdapterException */ -class ReaderTest extends \PHPUnit_Framework_TestCase +class ReaderTest extends \PHPUnit\Framework\TestCase { /** * @var \PHPExif\Reader\Reader @@ -14,7 +13,7 @@ class ReaderTest extends \PHPUnit_Framework_TestCase /** * Setup function before the tests */ - public function setUp() + public function setUp() : void { $adapter = $this->getMockBuilder('\PHPExif\Adapter\AdapterInterface')->getMockForAbstractClass(); $this->reader = new \PHPExif\Reader\Reader($adapter); @@ -54,10 +53,10 @@ public function testGetAdapterFromProperty() * @group reader * @covers \PHPExif\Reader\Reader::getAdapter * @covers \PHPExif\Adapter\NoAdapterException - * @expectedException \PHPExif\Adapter\NoAdapterException */ public function testGetAdapterThrowsExceptionWhenNoAdapterIsSet() { + $this->expectException('\PHPExif\Adapter\NoAdapterException'); $reflProperty = new \ReflectionProperty('\PHPExif\Reader\Reader', 'adapter'); $reflProperty->setAccessible(true); $reflProperty->setValue($this->reader, null); @@ -84,10 +83,10 @@ public function testGetExifPassedToAdapter() /** * @group reader * @covers \PHPExif\Reader\Reader::factory - * @expectedException InvalidArgumentException */ public function testFactoryThrowsException() { + $this->expectException('InvalidArgumentException'); \PHPExif\Reader\Reader::factory('foo'); } @@ -132,6 +131,21 @@ public function testFactoryAdapterTypeExiftool() $this->assertInstanceOf('\PHPExif\Adapter\Exiftool', $adapter); } + /** + * @group reader + * @covers \PHPExif\Reader\Reader::factory + */ + public function testFactoryAdapterTypeFFprobe() + { + $reader = \PHPExif\Reader\Reader::factory(\PHPExif\Reader\Reader::TYPE_FFPROBE); + $reflProperty = new \ReflectionProperty('\PHPExif\Reader\Reader', 'adapter'); + $reflProperty->setAccessible(true); + + $adapter = $reflProperty->getValue($reader); + + $this->assertInstanceOf('\PHPExif\Adapter\FFprobe', $adapter); + } + /** * @group reader * @covers \PHPExif\Reader\Reader::getExifFromFile @@ -155,4 +169,3 @@ public function testGetExifFromFileCallsReadMethod() $this->assertEquals($expectedResult, $result); } } - diff --git a/tests/files/IMG_3824.MOV b/tests/files/IMG_3824.MOV new file mode 100644 index 0000000000000000000000000000000000000000..fa0aaf0511a365f160c0364cdbb8f0bb52c6e50e GIT binary patch literal 1429622 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