From: <li...@us...> - 2012-08-19 19:34:08
|
Revision: 42607 http://tikiwiki.svn.sourceforge.net/tikiwiki/?rev=42607&view=rev Author: lindonb Date: 2012-08-19 19:34:00 +0000 (Sun, 19 Aug 2012) Log Message: ----------- [ENH] Add files to process EXIF and XMP data and file that reconciles between metadata types in image files. Continues r42606 Revision Links: -------------- http://tikiwiki.svn.sourceforge.net/tikiwiki/?rev=42606&view=rev Added Paths: ----------- trunk/lib/metadata/exif.php trunk/lib/metadata/reconcile.php trunk/lib/metadata/xmp.php Added: trunk/lib/metadata/exif.php =================================================================== --- trunk/lib/metadata/exif.php (rev 0) +++ trunk/lib/metadata/exif.php 2012-08-19 19:34:00 UTC (rev 42607) @@ -0,0 +1,852 @@ +<?php +// (c) Copyright 2002-2012 by authors of the Tiki Wiki CMS Groupware Project +// +// All Rights Reserved. See copyright.txt for details and a complete list of authors. +// Licensed under the GNU LESSER GENERAL PUBLIC LICENSE. See license.txt for details. +// $Id$ + +//this script may only be included - so its better to die if called directly. +if (strpos($_SERVER['SCRIPT_NAME'],basename(__FILE__)) !== false) { + header('location: index.php'); + exit; +} +/* + * Manipulates EXIF metadata included within a file + */ +class Exif +{ + /** + * Legend, label, suffix and format information for each field + * See www.cipa.jp/english/hyoujunka/kikaku/pdf/DC-008-2010_E.pdf for specification source for Exif version 2.3 + * + * @var array + */ + var $specs = array( + //the PHP function exif_read_data adds the FILE group when returning exif data + 'FILE' => array( + 'FileDateTime' => array( + 'label' => 'Data Extraction Time', + ), + 'FileSize' => array( + 'suffix' => 'bytes', + ), + 'FileType' => array( + //from http://php.net/manual/function.exif-imagetype.php + 'options' => array( + '1' => 'GIF', + '2' => 'JPEG', + '3' => 'PNG', + '4' => 'SWF', + '5' => 'PSD', + '6' => 'BMP', + '7' => 'TIFF II (Intel byte order)', + '8' => 'TIFF MM (Motorola byte order)', + '9' => 'JPC', + '10' => 'JP2', + '11' => 'JPX', + '12' => 'JB2', + '13' => 'SWC', + '14' => 'IFF', + '15' => 'WBMP', + '16' => 'XBM', + '17' => 'ICO', + ), + ), + ), + 'COMPUTED' => array( + 'html' => array( + 'label' => 'HTML', + ), + 'Height' => array( + 'suffix' => 'pixels', + ), + 'Width' => array( + 'suffix' => 'pixels', + ), + 'IsColor' => array( + 'options' => array( + 0 => 'No', + 1 => 'Yes', + ), + ), + 'ByteOrderMotorola' => array( + 'options' => array( + 0 => 'No', + 1 => 'Yes', + ), + ), + 'Thumbnail.FileType' => array( + 'label' => 'Thumbnail File Type', + //from http://php.net/manual/function.exif-imagetype.php + 'options' => array( + '1' => 'GIF', + '2' => 'JPEG', + '3' => 'PNG', + '4' => 'SWF', + '5' => 'PSD', + '6' => 'BMP', + '7' => 'TIFF II (Intel byte order)', + '8' => 'TIFF MM (Motorola byte order)', + '9' => 'JPC', + '10' => 'JP2', + '11' => 'JPX', + '12' => 'JB2', + '13' => 'SWC', + '14' => 'IFF', + '15' => 'WBMP', + '16' => 'XBM', + '17' => 'ICO', + ), + ), + 'Thumbnail.MimeType' => array( + 'label' => 'Thumbnail Mime Type', + ), + ), + 'IFD0' => array( + 'ImageWidth' => array( + 'suffix' => 'pixels', + ), + 'ImageLength' => array( + 'suffix' => 'pixels', + ), + 'Compression' => array( + 'options' => array( + '1' => 'Uncompressed', + '2' => 'CCITT 1D', + '3' => 'T4/Group 3 Fax', + '4' => 'T6/Group 4 Fax', + '5' => 'LZW', + '6' => 'JPEG (old-style)', + '7' => 'JPEG', + '8' => 'Adobe Deflate', + '9' => 'JBIG B&W', + '10' => 'JBIG Color', + '99' => 'JPEG', + '262' => 'Kodak 262', + '32766' => 'Next', + '32767' => 'Sony ARW Compressed', + '32769' => 'Packed RAW', + '32770' => 'Samsung SRW Compressed', + '32771' => 'CCIRLEW', + '32773' => 'PackBits', + '32809' => 'Thunderscan', + '32867' => 'Kodak KDC Compressed', + '32895' => 'IT8CTPAD', + '32896' => 'IT8LW', + '32897' => 'IT8MP', + '32898' => 'IT8BL', + '32908' => 'PixarFilm', + '32909' => 'PixarLog', + '32946' => 'Deflate', + '32947' => 'DCS', + '34661' => 'JBIG', + '34676' => 'SGILog', + '34677' => 'SGILog24', + '34712' => 'JPEG 2000', + '34713' => 'Nikon NEF Compressed', + '34715' => 'JBIG2 TIFF FX', + '34718' => 'Microsoft Document Imaging (MDI) Binary Level Codec', + '34719' => 'Microsoft Document Imaging (MDI) Progressive Transform Codec', + '34720' => 'Microsoft Document Imaging (MDI) Vector', + '65000' => 'Kodak DCR Compressed', + '65535' => 'Pentax PEF Compressed)', + ), + ), + 'PhotometricInterpretation' => array( + 'options' => array( + '0' => 'WhiteIsZero', + '1' => 'BlackIsZero', + '2' => 'RGB', + '3' => 'RGB Palette', + '4' => 'Transparency Mask', + '5' => 'CMYK', + '6' => 'YCbCr', + '8' => 'CIELab', + '9' => 'ICCLab', + '10' => 'ITULab', + '32803' => 'Color Filter Array', + '32844' => 'Pixar LogL', + '32845' => 'Pixar LogLuv', + '34892' => 'Linear Raw', + ), + ), + 'Orientation' => array( + 'options' => array( + '1' => 'Horizontal (normal)', + '2' => 'Mirror horizontal', + '3' => 'Rotate 180', + '4' => 'Mirror vertical', + '5' => 'Mirror horizontal and rotate 270 CW', + '6' => 'Rotate 90 CW', + '7' => 'Mirror horizontal and rotate 90 CW', + '8' => 'Rotate 270 CW', + ), + ), + 'XResolution' => array( + 'format' => 'rational', + 'suffix' => 'pixels (dots) per unit', + ), + 'YResolution' => array( + 'format' => 'rational', + 'suffix' => 'pixels (dots) per unit', + ), + 'PlanarConfiguration' => array( + 'options' => array( + '1' => 'Chunky', + '2' => 'Planar', + ), + ), + 'ResolutionUnit' => array( + 'options' => array( + '2' => 'inch', + '3' => 'cm', + ), + ), + 'WhitePoint' => array( + 'format' => 'rational', + ), + 'PrimaryChromaticities' => array( + 'format' => 'rational', + ), + 'YCbCrCoefficients' => array( + 'format' => 'rational', + ), + 'YCbCrSubSampling' => array( + 'options' => array( + '1 1' => 'YCbCr4:4:4 (1 1)', + '1 2' => 'YCbCr4:4:0 (1 2)', + '1 4' => 'YCbCr4:4:1 (1 4)', + '2 1' => 'YCbCr4:2:2 (2 1)', + '2 2' => 'YCbCr4:2:0 (2 2)', + '2 4' => 'YCbCr4:2:1 (2 4)', + '4 1' => 'YCbCr4:1:1 (4 1)', + '4 2' => 'YCbCr4:1:0 (4 2)', + ), + ), + 'YCbCrPositioning' => array( + 'options' => array( + 1 => 'Centered', + 2 => 'Co-sited', + ), + ), + 'ReferenceBlackWhite' => array( + 'format' => 'rational', + ), + 'JPEGInterchangeFormat' => array( + 'label' => 'Thumbnail Offset', + ), + 'JPEGInterchangeFormatLength' => array( + 'label' => 'Thumbnail Length', + ), + 'Exif_IFD_Pointer' => array( + 'label' => 'EXIF IFD Pointer', + ), + 'GPS_IFD_Pointer' => array( + 'label' => 'GPS IFD Pointer', + ), ), + 'EXIF' => array( + 'ExposureTime' => array( + 'format' => 'rational', + 'suffix' => 'seconds', + ), + 'FNumber' => array( + 'format' => 'rational', + ), + 'ExposureProgram' => array( + 'options' => array( + '0' => 'Not Defined', + '1' => 'Manual', + '2' => 'Program AE', + '3' => 'Aperture-priority AE', + '4' => 'Shutter speed priority AE', + '5' => 'Creative (Slow speed)', + '6' => 'Action (High speed)', + '7' => 'Portrait', + '8' => 'Landscape', + '9' => 'Bulb', + ), + ), + 'SensitivityType' => array( + 'options' => array( + '0' => 'Unknown', + '1' => 'Standard Output Sensitivity', + '2' => 'Recommended Exposure Index', + '3' => 'ISO Speed', + '4' => 'Standard Output Sensitivity and Recommended Exposure Index', + '5' => 'Standard Output Sensitivity and ISO Speed', + '6' => 'Recommended Exposure Index and ISO Speed', + '7' => 'Standard Output Sensitivity, Recommended Exposure Index and ISO Speed', + ), + ), + 'ComponentsConfiguration' => array( + 'binary' => true, + 'options' => array( + '00' => '- ', + '01' => 'Y ', + '02' => 'Cb', + '03' => 'Cr', + '04' => 'R ', + '05' => 'G ', + '06' => 'B ', + ), + ), + 'CompressedBitsPerPixel' => array( + 'format' => 'rational', + ), + 'ShutterSpeedValue' => array( + 'format' => 'rational', + 'suffix' => 'APEX' + ), + 'ApertureValue' => array( + 'format' => 'rational', + 'suffix' => 'APEX' + ), + 'BrightnessValue' => array( + 'format' => 'rational', + 'suffix' => 'APEX' + ), + 'ExposureBiasValue' => array( + 'format' => 'rational', + 'suffix' => 'APEX' + ), + 'MaxApertureValue' => array( + 'format' => 'rational', + 'suffix' => 'APEX' + ), + 'SubjectDistance' => array( + 'format' => 'rational', + 'suffix' => 'meters' + ), + 'MeteringMode' => array( + 'options' => array( + '0' => 'Unknown', + '1' => 'Average', + '2' => 'Center-weighted average', + '3' => 'Spot', + '4' => 'Multi-spot', + '5' => 'Multi-segment', + '6' => 'Partial', + '255' => 'Other', + ), + ), + 'LightSource' => array( + 'options' => array( + '0' => 'Unknown', + '1' => 'Daylight', + '2' => 'Fluorescent', + '3' => 'Tungsten (Incandescent)', + '4' => 'Flash', + '9' => 'Fine Weather', + '10' => 'Cloudy', + '11' => 'Shade', + '12' => 'Daylight Fluorescent', + '13' => 'Day White Fluorescent', + '14' => 'Cool White Fluorescent', + '15' => 'White Fluorescent', + '16' => 'Warm White Fluorescent', + '17' => 'Standard Light A', + '18' => 'Standard Light B', + '19' => 'Standard Light C', + '20' => 'D55', + '21' => 'D65', + '22' => 'D75', + '23' => 'D50', + '24' => 'ISO Studio Tungsten', + '255' => 'Other', + ), + ), + 'Flash' => array( + 'options' => array( + '0' => 'No Flash', + '1' => 'Fired', + '5' => 'Fired, Return not detected', + '7' => 'Fired, Return detected', + '8' => 'On, Did not fire', + '9' => 'On, Fired', + '13' => 'On, Return not detected', + '15' => 'On, Return detected', + '16' => 'Off, Did not fire', + '20' => 'Off, Did not fire, Return not detected', + '24' => 'Auto, Did not fire', + '25' => 'Auto, Fired', + '29' => 'Auto, Fired, Return not detected', + '31' => 'Auto, Fired, Return detected', + '32' => 'No flash function', + '48' => 'Off, No flash function', + '65' => 'Fired, Red-eye reduction', + '69' => 'Fired, Red-eye reduction, Return not detected', + '71' => 'Fired, Red-eye reduction, Return detected', + '73' => 'On, Red-eye reduction', + '77' => 'On, Red-eye reduction, Return not detected', + '79' => 'On, Red-eye reduction, Return detected', + '80' => 'Off, Red-eye reduction', + '88' => 'Auto, Did not fire, Red-eye reduction', + '89' => 'Auto, Fired, Red-eye reduction', + '93' => 'Auto, Fired, Red-eye reduction, Return not detected', + '95' => 'Auto, Fired, Red-eye reduction, Return detected', + ), + ), + 'FocalLength' => array( + 'format' => 'rational', + 'suffix' => 'mm', + ), + /* //don't convert since already converted by PHP + 'UserComment' => array( + 'binary' => true, + ),*/ + 'ColorSpace' => array( + 'options' => array( + '1' => 'sRGB', + '65533' => 'Wide Gamut RGB', + '65534' => 'ICC Profile', + '65535' => 'Uncalibrated', + ), + ), + 'ExifImageWidth' => array( + 'label' => 'Image Width', + 'suffix' => 'pixels', + ), + 'ExifImageLength' => array( + 'label' => 'Image Height', + 'suffix' => 'pixels', + ), + 'FlashEnergy' => array( + 'format' => 'rational', + 'suffix' => 'BCPS', + ), + 'FocalPlaneXResolution' => array( + 'format' => 'rational', + 'suffix' => 'pixels per unit', + ), + 'FocalPlaneYResolution' => array( + 'format' => 'rational', + 'suffix' => 'pixels per unit', + ), + 'FocalPlaneResolutionUnit' => array( + 'options' => array( + 1 => 'None', + 2 => 'inch', + 3 => 'cm', + 4 => 'mm', + 5 => 'um', + ), + ), + 'ExposureIndex' => array( + 'format' => 'rational', + ), + 'SensingMethod' => array( + 'options' => array( + 1 => 'Not defined', + 2 => 'One-chip color area', + 3 => 'Two-chip color area', + 4 => 'Three-chip color area', + 5 => 'Color sequential area', + 7 => 'Trilinear', + 8 => 'Color sequential linear', + ), + ), + 'FileSource' => array( + 'binary' => true, + 'options' => array( + '00' => 'Others', + '01' => 'Film Scanner ', + '02' => 'Reflection Print Scanner', + '03' => 'Digital Camera', + ), + ), + 'SceneType' => array( + 'binary' => true, + 'options' => array( + '01' => 'Directly photographed', + ), + ), + 'CFAPattern' => array( + 'binary' => true, + 'options' => array( + '00' => 'Red', + '01' => 'Green', + '02' => 'Blue', + '03' => 'Cyan', + '04' => 'Magenta', + '05' => 'Yellow', + '06' => 'White', + ), + ), + 'CustomRendered' => array( + 'options' => array( + '0' => 'Normal', + '1' => 'Custom', + ), + ), + 'ExposureMode' => array( + 'options' => array( + '0' => 'Auto', + '1' => 'Manual', + '2' => 'Auto bracket', + ), + ), + 'WhiteBalance' => array( + 'options' => array( + '0' => 'Auto', + '1' => 'Manual', + ), + ), + 'SceneCaptureType' => array( + 'options' => array( + '0' => 'Standard', + '1' => 'Landscape', + '2' => 'Portrait', + '3' => 'Night', + ), + ), + 'GainControl' => array( + 'options' => array( + '0' => 'None', + '1' => 'Low gain up', + '2' => 'High gain up', + '3' => 'Low gain down', + '4' => 'High gain down', + ), + ), + 'Contrast' => array( + 'options' => array( + '0' => 'Normal', + '1' => 'Low', + '2' => 'High', + ), + ), + 'Saturation' => array( + 'options' => array( + '0' => 'Normal', + '1' => 'Low', + '2' => 'High', + ), + ), + 'Sharpness' => array( + 'options' => array( + '0' => 'Normal', + '1' => 'Soft', + '2' => 'Hard', + ), + ), + 'SubjectDistanceRange' => array( + 'options' => array( + '0' => 'Unknown', + '1' => 'Macro', + '2' => 'Close', + '3' => 'Distant', + ), + ), + //some legitimate exif fields are apparently only partially handled by php + //an undefined tag is returned instead of the label + 'UndefinedTag:0xA431' => array( + 'label' => 'Serial Number', + ), + 'UndefinedTag:0xA432' => array( + 'label' => 'Lens Specification', + 'suffix'=> 'Min - max focal length in mm; Min - max Fnumber' + ), + 'UndefinedTag:0xA434' => array( + 'label' => 'Lens Model', + ), + ), + 'GPS' => array( + 'GPSVersion' => array( + 'binary' => true, + ), + 'GPSLatitudeRef' => array( + 'options' => array( + 'N' => 'North (+)', + 'S' => 'South (-)', + ), + ), + 'GPSLatitude' => array( + 'format' => 'rational', + ), + 'GPSLongitudeRef' => array( + 'options' => array( + 'E' => 'East (+)', + 'W' => 'West (-)', + ), + ), + 'GPSLongitude' => array( + 'format' => 'rational', + ), + 'GPSAltitudeRef' => array( + 'binary' => true, + 'options' => array( + '00' => 'Meters above sea level', + '01' => 'Meters below sea level', + ), + ), + 'GPSAltitude' => array( + 'format' => 'rational', + ), + 'GPSTimeStamp' => array( + 'format' => 'rational', + 'suffix' => '(24-hour clock)', + ), + 'GPSStatus' => array( + 'options' => array( + 'A' => 'Measurement Active', + 'V' => 'Measurement Void', + ), + ), + 'GPSMeasureMode' => array( + 'options' => array( + '2' => '2-Dimensional Measurement', + '3' => '3-Dimensional Measurement', + ), + ), + 'GPSSpeedRef' => array( + 'options' => array( + 'K' => 'km/h', + 'M' => 'mph', + 'N' => 'knots', + ), + ), + 'GPSSpeed' => array( + 'format' => 'rational', + ), + 'GPSTrackRef' => array( + 'options' => array( + 'M' => 'Magnetic North', + 'T' => 'True North', + ), + ), + 'GPSTrack' => array( + 'format' => 'rational', + ), + 'GPSImgDirectionRef' => array( + 'options' => array( + 'M' => 'Magnetic North', + 'T' => 'True North', + ), + ), + 'GPSImgDirection' => array( + 'format' => 'rational', + ), + 'GPSDifferential' => array( + 'options' => array( + '0' => 'No Corection', + 'T' => 'Differential Corrected', + ), + ), + 'GPSDestLatitudeRef' => array( + 'options' => array( + 'N' => 'North (+)', + 'S' => 'South (-)', + ), + ), + 'GPSDestLatitude' => array( + 'format' => 'rational', + ), + 'GPSDestLongitudeRef' => array( + 'options' => array( + 'E' => 'East (+)', + 'W' => 'West (-)', + ), + ), + 'GPSDestLongitude' => array( + 'format' => 'rational', + ), + 'GPSDestBearingRef' => array( + 'options' => array( + 'M' => 'Magnetic North', + 'T' => 'True North', + ), + ), + 'GPSDestBearing' => array( + 'format' => 'rational', + ), + 'GPSDestDistanceRef' => array( + 'options' => array( + 'K' => 'Kilometers', + 'M' => 'Miles', + 'N' => 'Nautical Miles', + ), + ), + 'GPSDestDistance' => array( + 'format' => 'rational', + ), + 'GPSHPositioningError' => array( + 'format' => 'rational', + ), + ), + ); + + /** + * Process raw EXIF data by converting binary or hex information, replacing legend codes with their meanings, + * fixing labels, etc. + * + * @param array $exifraw Array of raw EXIF data + * + * @return array $exif Array of processed EXIF data, including label, newval and suffix values + * for each field + */ + function processRawData($exifraw) + { + $filter = new Zend_Filter_Word_CamelCaseToSeparator(); + //array of tags to match exif array from file + foreach ($exifraw as $group => $fields) { + foreach ($fields as $name => $field) { + if (isset($field)) { + //store raw value + $exif[$group][$name]['rawval'] = $field; + + //thumbnail and ifd0 groups share the same specifications + $groupmask = $group == 'THUMBNAIL' ? 'IFD0' : $group; + + //get tag data from $specs array + if (isset($this->specs[$groupmask][$name])) { + //shorten the variable + $specname = $this->specs[$groupmask][$name]; + + //convert binary values + if (isset($specname['binary']) && $specname['binary']) { + $exif[$group][$name]['rawval'] = bin2hex($exif[$group][$name]['rawval']); + } + + //start processing rawval into newval + if (is_array($exif[$group][$name]['rawval'])) { + $exif[$group][$name]['newval'] = $this->processArray($exif[$group][$name]['rawval'], $name); + //perform division for rational fields, but only if not an array + } elseif (isset($specname['format']) && $specname['format'] == 'rational') { + $exif[$group][$name]['newval'] = $this->divide($field); + //move rest of rawvals into newvals + } else { + $exif[$group][$name]['newval'] = $exif[$group][$name]['rawval']; + } + + //now determine display values using option values where they exist + if (isset($specname['options'])) { + //first handle special cases + if ($name == 'ComponentsConfiguration') { + $str = $exif[$group][$name]['newval']; + $opt = $specname['options']; + $disp = $opt[substr($str, 0, 2)] . ' ' . $opt[substr($str, 2, 2)] . ' ' + . $opt[substr($str, 4, 2)] . ' ' . $opt[substr($str, 6, 2)]; + $exif[$group][$name]['newval'] = $disp; + } elseif($name == 'CFAPattern') { + $str = $exif[$group][$name]['newval']; + $opt = $specname['options']; + $disp = '[' . $opt[substr($str, 8, 2)] . ', ' . $opt[substr($str, 10, 2)] . '] [' + . $opt[substr($str, 12, 2)] . ', ' . $opt[substr($str, 14, 2)] . ']'; + $exif[$group][$name]['newval'] =$disp; + } else { + $exif[$group][$name]['newval'] = $specname['options'][$exif[$group][$name]['newval']]; + } + } + + //fix labels + if (isset($specname['label'])) { + $exif[$group][$name]['label'] = $specname['label']; + } else { + //create reading-friendly labels from camel case tags + $exif[$group][$name]['label'] = $filter->filter($name); + } + + if (isset($specname['suffix']) && !is_array($exif[$group][$name]['newval'])) { + $exif[$group][$name]['suffix'] = $specname['suffix']; + } + } else { + //those not covered in $specs + $exif[$group][$name]['newval'] = $exif[$group][$name]['rawval']; + //create reading-friendly labels from camel case tags + $exif[$group][$name]['label'] = $filter->filter($name); + } + } + } + } + //*******Special Handling*********// + //file name is computed by PHP and is meaningless when file is stored in tiki, + //and dialog box has real name in title so not needed + unset($exif['FILE']['FileName']); + //No processing of maker notes yet as specific code is needed for each manufacturer + //Blank out field since it is very long and will distort the dialog box + if (!empty($exif['EXIF']['MakerNote']['value']['display'])) { + $exif['EXIF']['MakerNote']['newval'] = '(Not processed)'; + } + //PHP computed field (shows time data was extracted) returns 0 for external images so delete when so + if (isset($exif['FILE']['FileDateTime']['newval']) && $exif['FILE']['FileDateTime']['newval'] == 0) { + unset($exif['FILE']['FileDateTime']); + } elseif (!empty($exif['FILE']['FileDateTime']['newval'])) { + global $tikilib, $user; + $exif['FILE']['FileDateTime']['newval'] = $tikilib->get_long_datetime($exif['FILE']['FileDateTime']['newval'], $user); + } + //Interpret GPSVersion field + if (isset($exif['GPS']['GPSVersion'])) { + $exif['GPS']['GPSVersion']['newval'] = ''; + $len = strlen($exif['GPS']['GPSVersion']['rawval']); + for ($i = 0; $i < $len; $i = $i + 2) { + if ($i > 0) { + $exif['GPS']['GPSVersion']['newval'] .= '.'; + } + $exif['GPS']['GPSVersion']['newval'] .= (int) substr($exif['GPS']['GPSVersion']['rawval'], $i, 2); + } + } + //PHP already converts UserComment in the Computed group so use that value + if (isset($exif['EXIF']['UserComment']) && isset($exif['COMPUTED']['UserComment'])) { + $exif['EXIF']['UserComment']['newval'] = $exif['COMPUTED']['UserComment']['rawval']; + unset($exif['COMPUTED']['UserComment']); + } + //PHP already converts the FNumber in the Computed group so use that value + if (isset($exif['EXIF']['FNumber']) && isset($exif['COMPUTED']['ApertureFNumber'])) { + $exif['EXIF']['FNumber']['newval'] = $exif['COMPUTED']['ApertureFNumber']['rawval']; + unset($exif['COMPUTED']['ApertureFNumber']); + } + + return $exif; + } + + /** + * Perform division on values in a rational format, e.g. '100/5'. Accepts arrays or single values + * + * @param string or array $fractionString + * + * @return array|float + */ + function divide($fractionString) + { + if (!is_array($fractionString)) { + $fraction = explode('/', $fractionString); + $ret = $fraction[0] / $fraction[1]; + } else { + foreach ($fractionString as $fs) { + $fraction = explode('/', $fs); + $ret[] = $fraction[0] / $fraction[1]; + } + } + return $ret; + } + + /** + * Deal with field values that are arrays, giving unique treatment to certain unique fields, otherwise + * converting to a string + * + * @param array $array field value + * @param string $fieldname field name used to identify where unique treatment should be applied + * + * @return string $ret field array converted into a string + */ + function processArray ($array, $fieldname) + { + $ret = ''; + if ($fieldname == 'GPSLatitude' || $fieldname == 'GPSLongitude') { + $calcarray = $this->divide($array); + $ret = $calcarray[0] + (($calcarray[1] + ($calcarray[2] / 60)) / 60); + } elseif ($fieldname == 'GPSTimeStamp') { + $array = $this->divide($array); + $ret = $array[0] . ':' . $array[1] . ':' . $array[2]; + } elseif ($fieldname == 'UndefinedTag:0xA432') { + $array = $this->divide($array); + $ret = $array[0] . ' - ' . $array[1] . '; ' . $array[2] . ' - ' . $array[3]; + } else { + foreach ($array as $value) { + $ret .= $value . '; '; + } + $ret .= tra('(values not interpreted)'); + } + return $ret; + } +} Added: trunk/lib/metadata/reconcile.php =================================================================== --- trunk/lib/metadata/reconcile.php (rev 0) +++ trunk/lib/metadata/reconcile.php 2012-08-19 19:34:00 UTC (rev 42607) @@ -0,0 +1,765 @@ +<?php +// (c) Copyright 2002-2012 by authors of the Tiki Wiki CMS Groupware Project +// +// All Rights Reserved. See copyright.txt for details and a complete list of authors. +// Licensed under the GNU LESSER GENERAL PUBLIC LICENSE. See license.txt for details. +// $Id$ + +//this script may only be included - so its better to die if called directly. +if (strpos($_SERVER['SCRIPT_NAME'],basename(__FILE__)) !== false) { + header('location: index.php'); + exit; +} +/** + * Reconciles metadata included within a file according to the Metadata Working Group guidelines + * See http://www.metadataworkinggroup.org/pdf/mwg_guidance.pdf + * Metadata in images particularly is not standardized and there are 3 main formats (IPTC, EXIF and XMP) + * that overlap. The guidelines set forth how these data should be reconciled + */ +class ReconcileExifIptcXmp +{ + /** + * Array of all the types of metadata handled by this class + * + * @var array + */ + var $alltypes = array('exif' => '', 'iptc' => '', 'xmp' => ''); + + /** + * Maps IPTC field labels to EXIF field labels + * + * @var array + */ + var $iptcToExif = array( + '2#055' => 'DateTimeOriginal', //date + '2#060' => 'DateTimeOriginalTime',//fake EXIF field to match IPTC time which is separated into a different field + '2#062' => 'DateTimeDigitized', //date + '2#063' => 'DateTimeDigitizedTime', //fake EXIF field to match IPTC time which is separated into a different field + '2#080' => 'Artist', + '2#116' => 'Copyright', + '2#120' => 'ImageDescription', + ); + + /** + * Maps IPTC field labels to XMP field labels + * XMP categories are indicated in comments + * + * @var array + */ + var $iptcToXmp = array ( + '2#004' => 'IntellectualGenre', //Iptc4xmpCore + '2#005' => 'title', //dc + '2#010' => 'Urgency', //photoshop + '2#012' => 'SubjectCode', //Iptc4xmpCore + '2#015' => 'Category', //photoshop + '2#020' => 'SupplementalCategories',//photoshop + '2#025' => 'subject', //dc + '2#040' => 'Instructions', //photoshop + '2#055' => 'DateCreated', //photoshop + '2#060' => 'DateCreatedTime', //fake XMP field to match IPTC time which is separated into a different field + '2#062' => 'CreateDate', //photoshop + '2#063' => 'CreateDateTime', //fake XMP field to match IPTC time which is separated into a different field + '2#080' => 'creator', //dc + '2#085' => 'AuthorsPosition', //photoshop + '2#090' => 'City', //photoshop + '2#092' => 'Location', //Iptc4xmpCore + '2#095' => 'State', //photoshop + '2#100' => 'CountryCode', //Iptc4xmpCore + '2#101' => 'Country', //photoshop + '2#103' => 'TransmissionReference', //photoshop + '2#105' => 'Headline', //photoshop + '2#110' => 'Credit', //photoshop + '2#115' => 'Source', //photoshop + '2#116' => 'rights', //dc + '2#118' => 'ContactInfoDetails', //Iptc4xmpCore + '2#120' => 'description', //dc + '2#122' => 'CaptionWriter', //photoshop + '2#140' => 'Instructions', //photosho + ); + + //Mapping for those fields where the name isn't the same and EXIF is preferred + /** + * Maps XMP field labels to EXIF labels where the labels aren't the same and EXIF is the preferred value + * per the Metadata Working Group guidelines + * + * @var array + */ + var $xmpToExif = array( + 'description' => 'ImageDescription', + 'rights' => 'Copyright', + 'creator' => 'Artist', + 'GPSVersionID' => 'GPSVersion', + 'FlashpixVersion' => 'FlashPixVersion', + 'PixelXDimension' => 'ExifImageWidth', + 'PixelYDimension' => 'ExifImageLength', + 'format' => 'MimeType', + 'ModifyDate' => 'DateTime', + 'DateCreated' => 'DateTimeOriginal', + 'CreateDate' => 'DateTimeDigitized', + 'LensInfo' => 'UndefinedTag:0xA432', + ); + + /** + * Maps EXIF field labals to XMP labels where the labels don't match and where XMP is preferred + * XMP is preferred for dates, if it matches EXIF, because it includes a time zone offset + * + * @var array + */ + var $xmpPreferred = array( + 'DateTime' => 'ModifyDate', + 'DateTimeOriginal' => 'DateCreated', + 'DateTimeDigitized' => 'CreateDate', + ); + + /** + * Specifications for summary key information to be placed first in the array of data + * + * @var array + */ + var $basicInfo = array( + 'User Data' => array( + 'Title' => array( + 'iptc' => '2#005', + 'xmp' => 'title', + ), + 'Description' => array( + 'exif' => 'ImageDescription', + 'iptc' => '2#120', + 'xmp' => 'description', + ), + 'Keywords' => array( + 'iptc' => '2#025', + 'xmp' => 'subject', + ), + 'Creator' => array( + 'exif' => 'Artist', + 'iptc' => '2#080', + 'xmp' => 'creator', + ), + 'Copyright' => array( + 'exif' => 'Copyright', + 'iptc' => '2#116', + 'xmp' => 'rights', + ), + ), + 'Dates' => array( + 'Date of Original' => array( + 'exif' => 'DateTimeOriginal', + 'iptc' => '2#055', + 'xmp' => 'DateCreated' + ), + 'Date Digitized' => array( + 'exif' => 'DateTimeDigitized', + 'iptc' => '2#062', + 'xmp' => 'CreateDate', + ), + 'Date Modified' => array( + 'exif' => 'DateTime', + 'xmp' => 'ModifyDate', + ), + 'Metadata Date' => array( + 'xmp' => 'MetadataDate', + ), + ), + 'File Data' => array( + 'File Type' => array ( + 'exif' => 'FileType', + 'xmp' => 'format', + ), + 'File Size' => array( + 'exif' => 'FileSize', + ), + 'Height' => array( + 'exif' => 'ExifImageLength', + 'xmp' => 'PixelYDimension', + ), + 'Width' => array( + 'exif' => 'ExifImageWidth', + 'xmp' => 'PixelXDimension', + ), + 'Resolution' => array( + 'exif' => 'XResolution', + ), + 'Resolution Unit' => array( + 'exif' => 'ResolutionUnit', + ), + ), + ); + /** + * Labels for reconciliation stats + * + * @var array + */ + var $statspecs = array ( + 'fields' => array( + 'label' => 'Total Fields Shown', + ), + 'dupes' => array( + 'label' => 'Duplicate Fields' + ), + 'mismatches' => array( + 'label' => 'Mismatches', + ), + ); + + /** + * Array used to determine which data types to compare based on which iteration we're on + * + * @var array + */ + var $repeat = array( + 2 => array('exif' => '', 'iptc' => ''), + 3 => array('iptc' => '', 'xmp' => ''), + 4 => array('exif' => '', 'xmp' => ''), + 5 => array('exif' => '', 'xmp' => ''), + ); + + /** + * Map between xmp (keys) and exif (values) for the FLash field + * xmp stores as different fields whereas exif stores as one number + * + * @var array + */ + var $flashmap = array ( + 'Fired' => array( + 'False' => 0, + 'True' => 1, + ), + 'Return' => array( + '0' => 0, //No return detected + '2' => 4, //Return not detected + '3' => 6, //Return detected + ), + 'Mode' => array( + '0' => 0, //Unknown + '1' => 8, //On + '2' => 16, //Off + '3' => 24, //Auto + ), + 'Function' => array( + 'False' => 0, + 'True' => 32, + ), + 'RedEyeMode' => array( + 'False' => 0, + 'True' => 64, + ), + ); + + /** + * Fields requiring special handling + * + * @var array + */ + var $special = array( + 'ComponentsConfiguration' => '', + ); + + /** + * Reconcile EXIF, IPTC and XMP metadata and return a single reconciled array + * + * @param array $metadata Expects the following format + * $metadata[type-eg IPTC][group-eg GPS][field-eg height] + * + * @return array|bool $finalall Array of reconciled data included stats + */ + function reconcileAllMeta($metadata) + { +// $types = array(); + //check which metadata types exist + foreach($this->alltypes as $alltype => $val) { + if ($metadata[$alltype] !== false) { + $types[$alltype] = ''; + } + } + //return false if no metadata + if (count($types) == 0) { + return false; + //return unaltered metadata if only one type exists + } elseif (count($types) == 1) { + return $metadata[key($types)]; + //more than one metadata type, so need to reconcile + } else { + //set main array with all data from all types + foreach($types as $type => $val) { + $omni[$type]['flat'] = $this->flatten($metadata[$type]); + } + $omni = $this->addFakeFields($omni); + foreach ($omni as $type => $flat) { + $omni[$type]['left'] = $omni[$type]['flat']; + } + //send to reconciling function + //if all three types are present, will need to iterate 5 times in total + if (count($types) == count($this->alltypes)) { + $omni = $this->reconcile($types, $omni, false, 1); + //if exif and xmp are the types, then will need to iterate twice: once for matching field names and once for + //mapped field names + } elseif (array_key_exists('exif', $types) && array_key_exists('xmp', $types)) { + $omni = $this->reconcile($types, $omni, false, 4); + //other combinations of two data types are only iterated once + } else { + $omni = $this->reconcile($types, $omni, false, 5); + } + //combine duplicated fields with unduplicated for final array + $omni['stats']['fields']['newval'] = 0; + foreach($types as $type => $val) { + if (isset($omni[$type]['left']) && count($omni[$type]['left']) > 0) { + if (!isset($omni['all'][$type])) { + $omni['all'][$type] = $omni[$type]['left']; + $omni['stats']['fields']['newval'] += count($omni['all'][$type]); + } else { + $omni['all'][$type] += $omni[$type]['left']; + $omni['stats']['fields']['newval'] += count($omni['all'][$type]); + } + } + } + } + //Prepare stats + $stats = ''; + if (isset($omni['stats'])) { + foreach($this->statspecs as $key => $array) { + if (isset($omni['stats'][$key])) { + $stats[$key] = $omni['stats'][$key]; + $stats[$key]['label'] = $this->statspecs[$key]['label']; + } + } + if (isset($stats['mismatches']['newval'])) { + $stats['mismatches']['suffix'] = '(fields that should match but do not - see data detail)'; + } + if (isset($stats['dupes']['newval']) && $stats['dupes']['newval'] > 0) { + $stats['dupes']['suffix'] = '(this is normal - standard preferred field shown)'; + } + } + + //extract basic information to display first + $basicinfo = ''; + foreach($this->basicInfo as $infogroup => $fields) { + foreach($fields as $label => $infotypes) { + foreach($infotypes as $infotype => $fieldame) { + if (isset($omni['all'][$infotype][$fieldame])) { + $basicinfo['Summary of Basic Information'][$infogroup][$label] + = $omni['all'][$infotype][$fieldame]; + $basicinfo['Summary of Basic Information'][$infogroup][$label]['label'] + = $label; + } + } + } + } + //add stats + if (isset($stats)) { + $basicinfo['Summary of Basic Information']['Metadata Stats'] = $stats; + } + + //unflatten the file metadata arrays by restoring the group level + foreach($types as $type => $val) { + if (isset($metadata[$type])) { + foreach($metadata[$type] as $group => $fields) { + $finalall[$type][$group] = array_intersect_key($omni['all'][$type], $fields); + } + } + } + $finalall = $basicinfo + $finalall; + return $finalall; + } + + /** + * @param $multiArray + * + * @return array + */ + function flatten($multiArray) + { + $flat = array(); + foreach ($multiArray as $secondkeys) { + $flat = $flat + $secondkeys; + } + return $flat; + } + + function addFakeFields($omni) + { + if (array_key_exists('2#060', $omni['iptc']['flat'])) { + if (array_key_exists('DateTimeOriginal', $omni['exif']['flat'])) { + $omni['exif']['flat']['DateTimeOriginalTime'] = $omni['exif']['flat']['DateTimeOriginal']; + } + if (array_key_exists('DateCreated', $omni['xmp']['flat'])) { + $omni['xmp']['flat']['DateCreatedTime'] = $omni['xmp']['flat']['DateCreated']; + } + } + if (array_key_exists('2#063', $omni['iptc']['flat'])) { + if (array_key_exists('DateTimeDigitized', $omni['exif']['flat'])) { + $omni['exif']['flat']['DateTimeDigitizedTime'] = $omni['exif']['flat']['DateTimeDigitized']; + } + if (array_key_exists('CreateDate', $omni['xmp']['flat'])) { + $omni['xmp']['flat']['CreateDateTime'] = $omni['xmp']['flat']['CreateDate']; + } + } + return $omni; + } + + /** + * Performs actual reconciliation of two data types. Multiple iterations are needed in some cases + * + * @param array $types Array of types of metadata included in the information + * @param array $omni Array of metadata to be reconciled + * @param bool $samekey Indicates whether the field labels for the two datatypes to be + * compared are the same or not + * @param numeric $i Indicates which data types to compare + * + * @return mixed + */ + function reconcile($types, $omni, $samekey = false, $i) + { + $match = array(); + //identify the types and determine matches + //for files with all 3 metadata types, first pass checks to see if any fields are triplicated + if (count($types) == 3) { + $type1 = 'exif'; + $type2 = 'iptc'; + $type3 = 'xmp'; + //extract actual EXIF fields that could be duplicated in IPTC + $exifmatch = array_flip(array_intersect_key(array_flip($this->iptcToExif), $omni['exif']['left'])); + //extract actual XMP fields that could be duplicated in IPTC + $xmpmatch = array_flip(array_intersect_key(array_flip($this->iptcToXmp), $omni['xmp']['left'])); + //now extract any triplicated fields (ie, fields in all three metadata types) + //resulting array will have IPTC => EXIF fieldname key => value pairs + $match = array_intersect_key($exifmatch, $xmpmatch, $omni['iptc']['left']); + //need an array with XMP fieldnames too + $matchx = array_flip(array_intersect_key($xmpmatch, $match)); + //for files with 2 metadata types, or for subsequent iterations after checking for triplicates for files + //with all three metadata types + } elseif (count($types) == 2) { + if ($samekey === false) { + if (array_key_exists('exif', $types)) { + $type1 = 'exif'; + $type2 = key(array_diff_key($types, array('exif' => ''))); + } else { + $type1 = 'xmp'; + $type2 = 'iptc'; + } + $map = $type2 . 'To' . ucfirst($type1); + //compare actual fields in type2 to list of possible duplicates with type1 + $two2one = array_intersect_key($this->$map, $omni[$type2]['left']); + $one2two = array_flip($two2one); + //compare possible duplicate list to actual type one fields to identify actual duplicates + $match = array_intersect_key($one2two, $omni[$type1]['left']); + //$samekey = true, which is for EXIF and XMP fields with the same field names, therefore no mapping needed + } else { + $type1 = 'exif'; + $type2 = 'xmp'; + $match = array_intersect_key($omni[$type1]['left'], $omni[$type2]['left']); + } + } + //start reconciling if there are duplicates + if (count($match) > 0) { + foreach($match as $name => $value) { + //set type => fieldname pairs for all metadata types in the file + if (count($types) == 3) { + $fnames = array( + $type1 => $exifmatch[$name], + $type2 => $name, + $type3 => $xmpmatch[$name], + ); + } else { + $fnames = array( + $type1 => $name, + $type2 => $samekey === false ? $one2two[$name] : $name, + ); + } + //check to see if duplicate fields have equal values + //check exif vs iptc + if (array_key_exists('exif', $types) && array_key_exists('iptc', $types)) { + $check['exif-iptc'] = $this->compareIptcExifValues($fnames['exif'], $fnames['iptc'], + $omni['iptc']['left'][$fnames['iptc']]['rawval'], + $omni['exif']['left'][$fnames['exif']]['rawval']); + } + //check exif vs xmp + if (array_key_exists('exif', $types) && array_key_exists('xmp', $types)) { + $check['exif-xmp'] = $this->compareExifXmpValues($fnames['exif'], + $omni['xmp']['left'][$fnames['xmp']]['rawval'], + $omni['exif']['left'][$fnames['exif']]['rawval']); + //per MWG guidelines, prefer XMP time fields to EXIF if they match since XMP has the time zone + //offset and EXIF doesn't + if (array_key_exists($fnames['exif'], $this->xmpPreferred) && $check['exif-xmp'] == true) { + $preferred = 'xmp'; + } else { + $preferred = 'exif'; + } + } + //check iptc vs xmp + if (array_key_exists('iptc', $types) && array_key_exists('xmp', $types)) { + $check['iptc-xmp'] = $this->compareIptcXmpValues($fnames['xmp'], $fnames['iptc'], + $omni['iptc']['left'][$fnames['iptc']]['rawval'], + $omni['xmp']['left'][$fnames['xmp']]['rawval']); + } + //now determine which of the duplicates will be displayed according to MWG guidelines + if (array_key_exists('iptc', $types)) { + //per MWG guidelines, if actual and stored IPTC hash are equal or stored is empty, + //prefer other values over IPTC + $hashmatch = $this->checkIptcHash($omni['iptc']['left']); + if ($hashmatch) { + if (count($types) == 3) { + $type = $preferred; + } else { + $type = $type1; + } + //per MWG guidelines, if actual and stored IPTC hash differ, prefer IPTC over EXIF, + //but prefer XMP if values match + } else { + if (array_key_exists('xmp', $types)) { + if ($check['iptc-xmp']) { + $type = 'xmp'; + } else { + $type = 'iptc'; + } + } else { + $type = 'iptc'; + } + } + //$type2 is XMP and $type1 is EXIF + } else { + //prefer XMP for certain date fields because they include time zone offset info + $type = $preferred; + } + + foreach ($fnames as $tname => $fname) { + //this is the field data that will be displayed + if ($type == $tname) { + if (isset($omni['all'][$type][$fname])) { + $omni['all'][$type][$fname] += $omni[$tname]['left'][$fname]; + } else { + $omni['all'][$type][$fname] = $omni[$tname]['left'][$fname]; + } + //this is the duplicate data that will be stored with the displayed data, but not displayed + } else { + $omni['all'][$type][$fnames[$type]][$tname][$fname] = $omni[$tname]['left'][$fname]; + } + } + //collect stats on mismatches + foreach($check as $typecheck => $result) { + $omni['all'][$type][$fnames[$type]]['check'][$typecheck] = $result; + if ($result === false) { + $omni['mismatches'][$type][$fnames[$type]][$typecheck] = $result; + if (!isset($omni['stats']['mismatches']['newval'])) { + $omni['stats']['mismatches']['newval'] = 1; + } else { + $omni['stats']['mismatches']['newval'] += 1; + } + $note = ' (' . strtoupper($typecheck) . ' ' . tra('duplicate fields do not match') . ')'; + if (!isset($omni['all'][$type][$fnames[$type]]['suffix'])) { + $omni['all'][$type][$fnames[$type]]['suffix'] = $note; + } + break; + } + } + } + //collect stats on how many duplicates + $count = $i == 1 ? count($match) * 3 : count($match); + if (!isset($omni['stats']['dupes']['newval'])) { + $omni['stats']['dupes']['newval'] = $count; + } else { + $omni['stats']['dupes']['newval'] += $count; + } + + //delete duplicates from complete field list to identify unduplicated fields that are left + if (count($types) == 3) { + $omni['exif']['left'] = array_diff_key($omni['exif']['left'], array_flip($match)); + $omni['iptc']['left'] = array_diff_key($omni['iptc']['left'], $match); + $omni['xmp']['left'] = array_diff_key($omni['xmp']['left'], $matchx); + } else { + $omni[$type1]['left'] = array_diff_key($omni[$type1]['left'], $match); + $omni[$type2]['left'] = array_diff_key($omni[$type2]['left'], + isset($samekey) && $samekey ? $match : array_flip($match)); + } + //see if the data needs another pass + //files with all three metadata types need 5 passes in total + //files with EXIF and XMP need 2 passes, one for fields with matching field names and one for mapped fields + if (isset($i) && $i < 5) { + $i++; + $newsamekey = $i == 4 ? true : false; + $newtypes = $this->repeat[$i]; + $omni = $this->reconcile($newtypes, $omni, $newsamekey, $i); + } + } else { + if (isset($i) && $i < 5) { + $i++; + $newsamekey = $i == 4 ? true : false; + $newtypes = $this->repeat[$i]; + $omni = $this->reconcile($newtypes, $omni, $newsamekey, $i); + } + } + return $omni; + } + + /** + * Check stored IPTC checksum against calculated checksum. Per Metadata Working Group guidelines, if there + * is no stored checksum or if it is there and matches the calculated checksum, then prefer the non-IPTC value + * + * @param array $iptcflat Array of IPTC data including the calulated and stored checksum values + * + * @return bool Return true when non-IPTC value should be used + */ + private function checkIptcHash($iptcflat) + { + if (!isset($iptcflat['iptchashstored']['newval']) || (strlen($iptcflat['iptchashstored']['newval']) > 0 + && $iptcflat['iptchashstored']['newval'] == $iptcflat['iptchashcurrent']['newval'])) + { + return true; + } else { + return false; + } + } + + /** + * Compare IPTC and EXIF values for fields that should be the same, taking into account IPTC length limitations + * + * @param string $iptcval Value of the IPTC field + * @param string $exifval Value of the EXIF field + * + * @return bool Return true or false depending on whether the fields matched or not + */ + private function compareIptcExifValues($exifkey, $iptckey, $iptcval, $exifval) + { + //handle special cases first + if (array_key_exists($exifkey, array('DateTimeDigitized' => '', 'DateTimeOriginal' => '', + 'DateTimeDigitizedTime' => '', 'DateTimeOriginalTime' => ''))) + { + $exifdate = new DateTime($exifval); + $iptcdate = new DateTime($iptcval); + //time + if ($iptckey == '2#060' || $iptckey == '2#063') { + $exifcheckval = $exifdate->format('H:i:s'); + $iptccheckval = $iptcdate->format('H:i:s'); + //date + } else { + $exifcheckval = $exifdate->format('Y-m-d'); + $iptccheckval = $iptcdate->format('Y-m-d'); + } + } else { + //IPTC fields have length limits, so compare up to the length of the IPTC field to avoid false negatives + $len = strlen($iptcval); + $exifcheckval = substr($exifval, 0, $len); + $iptccheckval = $iptcval; + } + if ($exifcheckval == $iptccheckval) { + return true; + } else { + return false; + } + } + + /** + * Compare IPTC and XMP fields that should have the same values, taking into account IPTC length limitations + * + * @param string $xmpkey XMP field name for special handling cases + * @param string $iptcval IPTC field value + * @param string $xmpval XMP field value + * + * @return bool Return true or false depending on whether the fields matched or not + */ + private function compareIptcXmpValues($xmpkey, $iptckey, $iptcval, $xmpval) + { + $iptccheckval = ''; + $xmpcheckval = ''; + //the subject field is an array in both IPTC and XMP, so concatenate to compare + if ($xmpkey == 'subject') { + foreach ($iptcval as $val) { + $iptccheckval .= $val; + } + foreach ($xmpval as $val) { + $xmpcheckval .= $val['rawval']; + } + } elseif (array_key_exists($xmpkey, array('DateCreated' => '', 'CreateDate' => '', + 'DateCreatedTime' => '', 'CreateDateTime' => ''))) + { + $xmpdate = new DateTime($xmpval); + $iptcdate = new DateTime($iptcval); + //time + if ($iptckey == '2#060' || $iptckey == '2#063') { + $xmpcheckval = $xmpdate->format('H:i:s'); + $iptccheckval = $iptcdate->format('H:i:s'); + //date + } else { + $xmpcheckval = $xmpdate->format('Y-m-d'); + $iptccheckval = $iptcdate->format('Y-m-d'); + } + } else { + //the ultimate raw value for XMP list fields (<li>) is one level deeper + if (is_array($xmpval)) { + $xmpcheckval = $xmpval['rawval']; + } else { + $xmpcheckval = $xmpval; + } + //IPTC fields have length limits, so compare up to the length of the IPTC field to avoid false negatives + $iptccheckval = $iptcval; + $xmpcheckval = substr($xmpcheckval, 0, strlen($iptcval)); + } + //now check against each other + if ($iptccheckval == $xmpcheckval) { + return true; + } else { + return false; + } + } + + /** + * Compare EXIF and XMP fields that should have the same values + * + * @param string $exifkey EXIF field name for fields that need special handling + * @param string $xmpval XMP field vale + * @param string $exifval EXIF field value + * + * @return bool Return true or false depending on whether the fields matched or not + */ + private function compareExifXmpValues($exifkey, $xmpval, $exifval) { + if (isset($xmpval)) { + //handle special cases + //XMP has the timezone offset for these fields whereas EXIF does not, so compare times without the offset + if ($exifkey == 'DateTimeOriginal' || $exifkey == 'DateTimeDigitized' || $exifkey == 'DateTime' + || $exifkey == 'DateTimeOriginalTime' || $exifkey == 'DateTimeDigitizedTime') + { + $exifcheckval = strtotime($exifval); + $xmpcheckval = strtotime(substr($xmpval, 0, strlen($exifval))); + //XMP GPS Version raw field is already in final format whereas EXIF field is not + } elseif ($exifkey == 'GPSVersion') { + $xmpcheckval = explode('.', $xmpval); + $xmpcheckval = '0' . implode('0', $xmpcheckval); + } elseif ($exifkey == 'ComponentsConfiguration') { + foreach ($xmpval as $val) { + $new = '0' . $val['rawval']; + $xmpcheckval = isset($xmpcheckval) ? $xmpcheckval . $new : $new; + } + } + //set EXIF value to check for all other cases + if (!isset($exifcheckval)) { + $exifcheckval = $exifval; + } + //when the XMP value is an array + if (is_array($xmpval) && !array_key_exists($exifkey, $this->special)) { + //Flash is an array in XMP and a single number code in EXIF + if ($exifkey == 'Flash') { + $exifcheckval = $exifval; + $xmpcheckval = ''; + foreach ($xmpval as $flash => $status) { + $xmpcheckval = $xmpcheckval + $this->flashmap[$flash][$status['rawval']]; + } + //the ultimate raw value for XMP list fields (<li>) is one level deeper + } else { + $xmpcheckval = $xmpval['rawval']; + } + } + //set XMP value to check for all other cases + if (!isset($xmpcheckval)) { + $xmpcheckval = $xmpval; + } + } else { + return false; + } + //now check against each other + if ($xmpcheckval == $exifcheckval) { + return true; + } else { + return false; + } + } +} Added: trunk/lib/metadata/xmp.php =================================================================== --- trunk/lib/metadata/xmp.php (rev 0) +++ trunk/lib/metadata/xmp.php 2012-08-19 19:34:00 UTC (rev 42607) @@ -0,0 +1,328 @@ +<?php +// (c) Copyright 2002-2012 by authors of the Tiki Wiki CMS Groupware Project +// +// All Rights Reserved. See copyright.txt for details and a complete list of authors. +// Licensed under the GNU LESSER GENERAL PUBLIC LICENSE. See license.txt for details. +// $Id$ + +//this script may only be included - so its better to die if called directly. +if (strpos($_SERVER['SCRIPT_NAME'],basename(__FILE__)) !== false) { + header('location: index.php'); + exit; +} +/* + * Manipulates XMP metadata included within a file + */ +class Xmp +{ + /** + * Legend and label information for each field + * + * @var array + */ + var $specs = array( + 'photoshop' => array( + 'ColorMode' => array( + 'options' => array( + '0' => 'Bitmap', + '1' => 'Gray scale', + '2' => 'Indexed color', + '3' => 'RGB color', + '4' => 'CMYK color', + '7' => 'Multi-channel', + '8' => 'Duotone', + '9' => 'LAB color', + ), + ), + ), + 'dc' => array( + 'rights' => array( + 'label' => 'Rights', + ), + 'description' => array( + 'label' => 'Description', + ), + 'title' => array( + 'label' => 'Title', + ), + 'subject' => array( + 'label' => 'Subject', + ), + 'format' => array( + 'label' => 'Format', + ), + 'creator' => array( + 'label' => 'Creator', + ), + ), + ); + + /** + * Fields requiring special handling + * + * @var array + */ + var $special = array( + 'ComponentsConfiguration' => '', + ); + + /** + * Process raw XMP string by converting to a DOM document, replacing legend codes with their meanings, + * fixing labels, etc. + * + * @param xml string$xmpstring + * + * @return array|bool + */ + function processRawData($xmpstring) + { + //need to do a little preparation before processing fields + if (!empty($xmpstring)) { + $xmp = new DOMDocument(); + //create a DOM Document from the XML string + $xmp->loadXML($xmpstring); + //convert Dom Document to an array + //TODO use Zend_Json::fromXml when zend fixes bug (see http://framework.zend.com/issues/browse/ZF-12224) + $xmparray = $this->xmpDomToArray($xmp); + //re-label Native Digest fields in tiff and exif sections to keep from overwriting when arrays are flattened + //in other functions + $sections = array('exif', 'tiff'); + foreach($sections as $section) { + if (isset($xmparray[$section]['NativeDigest'])) { + $temp = explode(',', $xmparray[$section]['NativeDigest']['rawval']); + $xmparray[$section]['NativeDigest']['rawval'] = implode(', ', $temp); + $xmparray[$section][strtoupper($section) . 'NativeDigest'] = $xmparray[$section]['NativeDigest']; + unset($xmparray[$section]['NativeDigest']); + } + } + //now we can process fields + $filter = new Zend_Filter_Word_CamelCaseToSeparator(); + foreach ($xmparray as $group => $fields) { + foreach ($fields as $name => $field) { + if (isset($this->specs[$group][$name])) { + //shorten the variable + $specname = $this->specs[$group][$name]; + //convert coded fields into tags + if (isset($specname['options'])) { + $xmparray[$group][$name]['newval'] = $specname['options'][$xmparray[$group][$name]['rawval']]; + } else { + $xmparray[$group][$name]['newval'] = $xmparray[$group][$name]['rawval']; + } + //fix labels + if (isset($specname['label'])) { + $xmparray[$group][$name]['label'] = $specname['label']; + } else { + //create reading-friendly labels from camel case tags + $xmparray[$group][$name]['label'] = $filter->filter($name); + } + } else { + //those not covered in $specs + //create reading-friendly labels from camel case tags + $xmparray[$group][$name]['label'] = $filter->filter($name); + $xmparray[$group][$name]['newval'] = $xmparray[$group][$name]['rawval']; + } + //deal with arrays + if (is_array($field['rawval'])) { + if (array_key_exists($name, $this->special)) { + $xmparray[$group][$name]['newval'] = $this->specialHandling($name, $field['rawval']); + } elseif (isset($field['rawval']['rawval'])) { + $xmparray[$group][$name]['newval'] = $field['rawval']['rawval']; + } elseif (isset($field['rawval'][0])) { + $xmparray[$group][$name]['newval'] = ''; + foreach ($field['rawval'] as $val) { + $xmparray[$group][$name]['newval'] .= $val['rawval'] . '; '; + } + } else { + $xmparray[$group][$name]['newval'] = ''; + foreach ($field['rawval'] as $val) { + $xmparray[$group][$name]['newval'] .= $val['label'] . ': ' . $val['rawval'] . '; '; + } + } + } + //convert dates + if (array_key_exists($name, array('ModifyDate' => '', 'DateCreated' => '', 'CreateDate' => '', + 'MetadataDate' => ''))) + { + $dateObj = new DateTime($xmparray[$group][$name]['newval']); + $date = $dateObj->format('Y-m-d H:i:s T'); + $xmparray[$group][$name]['newval'] = $date; + } + } + } + } else { + return false; + } + return $xmparray; + } + + /** + * Returns xmp metadata from a file as a fully formed xml string + * + * @param string $filecontent The file as a string (eg, after applying file_get_contents) + * @param string $filetype File type + * + * @return xml string|false $xmp_text Returns fully formed xml string + */ + function getXmp($filecontent, $filetype) + { + if ($filetype == 'image/jpeg') { + $done = false; + $start = 0; + //TODO need to be able to handle multiple segments + while ($done === false) { + //search for hexadecimal marker for segment APP1 used for xmp data, and note position + $app1_hit = strpos($filecontent, "\xFF\xE1", $start); + if ($app1_hit !== false) { + //next two bytes after marker indicate the segment size + $size_raw = substr($filecontent, $app1_hit + 2, 2); + $size = unpack('nsize', $size_raw); + /*the segment APP1 marker is also used for other things (like EXIF data), + so check that the segment starts with the right info + allowing for 2 bytes for the marker and 2 bytes for the size before segment data starts*/ + $seg_data = substr($filecontent, $app1_hit + 4, $size['size']); + $xmp_hit = strpos($seg_data, 'http://ns.adobe.com/xap/1.0/'); + if ($xmp_hit === 0) { + $xmp_text_start = strpos($seg_data, '<rdf:RDF'); + $xmp_text_end = strpos($seg_data, '</rdf:RDF>'); + $endlen = strlen('</rdf:RDF>'); + $xmp_length = $xmp_text_end + $endlen - $xmp_text_start; + $xmp_text = substr($seg_data, $xmp_text_start, $xmp_length); + } + //start at the end of the segment just searched for the next search + $start = $app1_hit + 4 + $size['size']; + } else { + $done = true; + } + } + if (!isset($xmp_text)) { + $xmp_text = false; + } + } else { + $xmp_text = false; + } + return $xmp_text; + } + + /** + * Convert an XML DomDocument from an image to an array + * + * @param DOM document $xmpObj XML document to process + * + * @return array|bool $xmparray Relevant portions of document converted to an array + */ + function xmpDomToArray($xmpObj) + { + if ($xmpObj !== false) { + //This section is for the first pass + if (get_class($xmpObj) == 'DOMDocument') { + //File metadata is in the Description elements + //There's one description element for each section of xmp data (exif,tiff, dc, etc.) + //$parent is a DOMNodeList + $topparents = $xmpObj->getElementsByTagName('Description'); + $toplen = $topparents->length; + //iterate through sections (like tiff, exif, xap, etc.) + for ($i = 0; $i < $toplen; $i++) { + //these sections (like exif, tiff, etc.) have child nodes, so no values captured at this level + //$children is a DOMNodeList + $children = $topparents->item($i)->childNodes; + $childrenlen = $children->length; + //iterate through fields in a section, e.g. Orientation, XResolution, etc. within tiff section + for ($j = 0; $j < $childrenlen; $j++) { + $child = $children->item($j); + //only pick up DOMElements to avoid empty DOMText fields + if ($child->nodeType == 1) { + //if $child has at least one child that is not a single DOMText field, then send back + //through to process children + if ($child->childNodes->length > 0 && !($child->childNodes->length == 1 + && $child->firstChild->nodeType != 1)) + { + $xmparray[$child->prefix][$child->localName]['... [truncated message content] |