36 private const BYTE = 1;
41 private const ASCII = 2;
44 private const SHORT = 3;
47 private const LONG = 4;
52 private const RATIONAL = 5;
55 private const SHORT_OR_LONG = 6;
58 private const UNDEFINED = 7;
61 private const SLONG = 9;
66 private const SRATIONAL = 10;
69 private const IGNORE = -1;
78 private $mRawExifData;
84 private $mFilteredExifData;
120 # TIFF Rev. 6.0 Attribute Information (p22)
122 # Tags relating to image structure
124 'ImageWidth' => self::SHORT_OR_LONG,
126 'ImageLength' => self::SHORT_OR_LONG,
127 # Number of bits per component
128 'BitsPerSample' => [ self::SHORT, 3 ],
130 # "When a primary image is JPEG compressed, this designation is not"
131 # "necessary and is omitted." (p23)
132 # Compression scheme #p23
133 'Compression' => self::SHORT,
134 # Pixel composition #p23
135 'PhotometricInterpretation' => self::SHORT,
136 # Orientation of image #p24
137 'Orientation' => self::SHORT,
138 # Number of components
139 'SamplesPerPixel' => self::SHORT,
140 # Image data arrangement #p24
141 'PlanarConfiguration' => self::SHORT,
142 # Subsampling ratio of Y to C #p24
143 'YCbCrSubSampling' => [ self::SHORT, 2 ],
144 # Y and C positioning #p24-25
145 'YCbCrPositioning' => self::SHORT,
146 # Image resolution in width direction
147 'XResolution' => self::RATIONAL,
148 # Image resolution in height direction
149 'YResolution' => self::RATIONAL,
150 # Unit of X and Y resolution #(p26)
151 'ResolutionUnit' => self::SHORT,
153 # Tags relating to recording offset
154 # Image data location
155 'StripOffsets' => self::SHORT_OR_LONG,
156 # Number of rows per strip
157 'RowsPerStrip' => self::SHORT_OR_LONG,
158 # Bytes per compressed strip
159 'StripByteCounts' => self::SHORT_OR_LONG,
161 'JPEGInterchangeFormat' => self::SHORT_OR_LONG,
163 'JPEGInterchangeFormatLength' => self::SHORT_OR_LONG,
165 # Tags relating to image data characteristics
167 'TransferFunction' => self::IGNORE,
168 # White point chromaticity
169 'WhitePoint' => [ self::RATIONAL, 2 ],
170 # Chromaticities of primarities
171 'PrimaryChromaticities' => [ self::RATIONAL, 6 ],
172 # Color space transformation matrix coefficients #p27
173 'YCbCrCoefficients' => [ self::RATIONAL, 3 ],
174 # Pair of black and white reference values
175 'ReferenceBlackWhite' => [ self::RATIONAL, 6 ],
178 # File change date and time
179 'DateTime' => self::ASCII,
181 'ImageDescription' => self::ASCII,
182 # Image input equipment manufacturer
183 'Make' => self::ASCII,
184 # Image input equipment model
185 'Model' => self::ASCII,
187 'Software' => self::ASCII,
188 # Person who created the image
189 'Artist' => self::ASCII,
191 'Copyright' => self::ASCII,
194 # Exif IFD Attribute Information (p30-31)
196 # @todo NOTE: Nonexistence of this field is taken to mean non-conformance
197 # to the Exif 2.1 AND 2.2 standards
198 'ExifVersion' => self::UNDEFINED,
199 # Supported Flashpix version #p32
200 'FlashPixVersion' => self::UNDEFINED,
202 # Tags relating to Image Data Characteristics
203 # Color space information #p32
204 'ColorSpace' => self::SHORT,
206 # Tags relating to image configuration
207 # Meaning of each component #p33
208 'ComponentsConfiguration' => self::UNDEFINED,
209 # Image compression mode
210 'CompressedBitsPerPixel' => self::RATIONAL,
212 'PixelYDimension' => self::SHORT_OR_LONG,
214 'PixelXDimension' => self::SHORT_OR_LONG,
216 # Tags relating to related user information
218 'MakerNote' => self::IGNORE,
220 'UserComment' => self::UNDEFINED,
222 # Tags relating to related file information
224 'RelatedSoundFile' => self::ASCII,
226 # Tags relating to date and time
227 # Date and time of original data generation #p36
228 'DateTimeOriginal' => self::ASCII,
229 # Date and time of original data generation
230 'DateTimeDigitized' => self::ASCII,
231 # DateTime subseconds
232 'SubSecTime' => self::ASCII,
233 # DateTimeOriginal subseconds
234 'SubSecTimeOriginal' => self::ASCII,
235 # DateTimeDigitized subseconds
236 'SubSecTimeDigitized' => self::ASCII,
238 # Tags relating to picture-taking conditions (p31)
240 'ExposureTime' => self::RATIONAL,
242 'FNumber' => self::RATIONAL,
243 # Exposure Program #p38
244 'ExposureProgram' => self::SHORT,
245 # Spectral sensitivity
246 'SpectralSensitivity' => self::ASCII,
248 'ISOSpeedRatings' => self::SHORT,
250 # Optoelectronic conversion factor. Note: We don't have support for this atm.
251 'OECF' => self::IGNORE,
254 'ShutterSpeedValue' => self::SRATIONAL,
256 'ApertureValue' => self::RATIONAL,
258 'BrightnessValue' => self::SRATIONAL,
260 'ExposureBiasValue' => self::SRATIONAL,
261 # Maximum land aperture
262 'MaxApertureValue' => self::RATIONAL,
264 'SubjectDistance' => self::RATIONAL,
266 'MeteringMode' => self::SHORT,
267 # Light source #p40-41
268 'LightSource' => self::SHORT,
270 'Flash' => self::SHORT,
272 'FocalLength' => self::RATIONAL,
274 'SubjectArea' => [ self::SHORT, 4 ],
276 'FlashEnergy' => self::RATIONAL,
277 # Spatial frequency response. Not supported atm.
278 'SpatialFrequencyResponse' => self::IGNORE,
279 # Focal plane X resolution
280 'FocalPlaneXResolution' => self::RATIONAL,
281 # Focal plane Y resolution
282 'FocalPlaneYResolution' => self::RATIONAL,
283 # Focal plane resolution unit #p46
284 'FocalPlaneResolutionUnit' => self::SHORT,
286 'SubjectLocation' => [ self::SHORT, 2 ],
288 'ExposureIndex' => self::RATIONAL,
289 # Sensing method #p46
290 'SensingMethod' => self::SHORT,
292 'FileSource' => self::UNDEFINED,
294 'SceneType' => self::UNDEFINED,
295 # CFA pattern. not supported atm.
296 'CFAPattern' => self::IGNORE,
297 # Custom image processing #p48
298 'CustomRendered' => self::SHORT,
300 'ExposureMode' => self::SHORT,
302 'WhiteBalance' => self::SHORT,
304 'DigitalZoomRatio' => self::RATIONAL,
305 # Focal length in 35 mm film
306 'FocalLengthIn35mmFilm' => self::SHORT,
307 # Scene capture type #p49
308 'SceneCaptureType' => self::SHORT,
309 # Scene control #p49-50
310 'GainControl' => self::SHORT,
312 'Contrast' => self::SHORT,
314 'Saturation' => self::SHORT,
316 'Sharpness' => self::SHORT,
318 # Device settings description. This could maybe be supported. Need to find an
319 # example file that uses this to see if it has stuff of interest in it.
320 'DeviceSettingDescription' => self::IGNORE,
322 # Subject distance range #p51
323 'SubjectDistanceRange' => self::SHORT,
326 'ImageUniqueID' => self::ASCII,
329 # GPS Attribute Information (p52)
331 'GPSVersion' => self::UNDEFINED,
332 # Should be an array of 4 Exif::BYTE's. However, php treats it as an undefined
333 # Note exif standard calls this GPSVersionID, but php doesn't like the id suffix
334 # North or South Latitude #p52-53
335 'GPSLatitudeRef' => self::ASCII,
337 'GPSLatitude' => [ self::RATIONAL, 3 ],
338 # East or West Longitude #p53
339 'GPSLongitudeRef' => self::ASCII,
341 'GPSLongitude' => [ self::RATIONAL, 3 ],
342 'GPSAltitudeRef' => self::UNDEFINED,
344 # Altitude reference. Note, the exif standard says this should be an EXIF::Byte,
345 # but php seems to disagree.
347 'GPSAltitude' => self::RATIONAL,
348 # GPS time (atomic clock)
349 'GPSTimeStamp' => [ self::RATIONAL, 3 ],
350 # Satellites used for measurement
351 'GPSSatellites' => self::ASCII,
352 # Receiver status #p54
353 'GPSStatus' => self::ASCII,
354 # Measurement mode #p54-55
355 'GPSMeasureMode' => self::ASCII,
356 # Measurement precision
357 'GPSDOP' => self::RATIONAL,
359 'GPSSpeedRef' => self::ASCII,
360 # Speed of GPS receiver
361 'GPSSpeed' => self::RATIONAL,
362 # Reference for direction of movement #p55
363 'GPSTrackRef' => self::ASCII,
364 # Direction of movement
365 'GPSTrack' => self::RATIONAL,
366 # Reference for direction of image #p56
367 'GPSImgDirectionRef' => self::ASCII,
369 'GPSImgDirection' => self::RATIONAL,
370 # Geodetic survey data used
371 'GPSMapDatum' => self::ASCII,
372 # Reference for latitude of destination #p56
373 'GPSDestLatitudeRef' => self::ASCII,
374 # Latitude destination
375 'GPSDestLatitude' => [ self::RATIONAL, 3 ],
376 # Reference for longitude of destination #p57
377 'GPSDestLongitudeRef' => self::ASCII,
378 # Longitude of destination
379 'GPSDestLongitude' => [ self::RATIONAL, 3 ],
380 # Reference for bearing of destination #p57
381 'GPSDestBearingRef' => self::ASCII,
382 # Bearing of destination
383 'GPSDestBearing' => self::RATIONAL,
384 # Reference for distance to destination #p57-58
385 'GPSDestDistanceRef' => self::ASCII,
386 # Distance to destination
387 'GPSDestDistance' => self::RATIONAL,
388 # Name of GPS processing method
389 'GPSProcessingMethod' => self::UNDEFINED,
391 'GPSAreaInformation' => self::UNDEFINED,
393 'GPSDateStamp' => self::ASCII,
394 # GPS differential correction
395 'GPSDifferential' => self::SHORT,
401 if ( $byteOrder ===
'BE' || $byteOrder ===
'LE' ) {
402 $this->byteOrder = $byteOrder;
407 wfWarn(
'Exif class did not have byte order specified. ' .
408 'Some properties may be decoded incorrectly.' );
410 $this->byteOrder =
'BE';
413 $this->debugFile( __FUNCTION__,
true );
414 if ( function_exists(
'exif_read_data' ) ) {
415 AtEase::suppressWarnings();
416 $data = exif_read_data( $this->file,
'',
true );
417 AtEase::restoreWarnings();
419 throw new MWException(
"Internal error: exif_read_data not present. " .
420 "\$wgShowEXIF may be incorrectly set or not checked by an extension." );
427 $this->mRawExifData = $data ?: [];
428 $this->makeFilteredData();
429 $this->collapseData();
430 $this->debugFile( __FUNCTION__,
false );
436 private function makeFilteredData() {
437 $this->mFilteredExifData = [];
439 foreach ( $this->mRawExifData as $section => $data ) {
440 if ( !array_key_exists( $section, $this->mExifTags ) ) {
441 $this->debug( $section, __FUNCTION__,
"'$section' is not a valid Exif section" );
445 foreach ( $data as $tag => $value ) {
446 if ( !array_key_exists( $tag, $this->mExifTags[$section] ) ) {
447 $this->debug( $tag, __FUNCTION__,
"'$tag' is not a valid tag in '$section'" );
451 if ( $this->validate( $section, $tag, $value ) ) {
454 $this->mFilteredExifData[$tag] = $value;
456 $this->debug( $value, __FUNCTION__,
"'$tag' contained invalid data" );
480 private function collapseData() {
481 $this->exifGPStoNumber(
'GPSLatitude' );
482 $this->exifGPStoNumber(
'GPSDestLatitude' );
483 $this->exifGPStoNumber(
'GPSLongitude' );
484 $this->exifGPStoNumber(
'GPSDestLongitude' );
486 if ( isset( $this->mFilteredExifData[
'GPSAltitude'] ) ) {
490 [ $num, $denom ] = explode(
'/', $this->mFilteredExifData[
'GPSAltitude'], 2 );
491 $this->mFilteredExifData[
'GPSAltitude'] = (int)$num / (
int)$denom;
493 if ( isset( $this->mFilteredExifData[
'GPSAltitudeRef'] ) ) {
494 switch ( $this->mFilteredExifData[
'GPSAltitudeRef'] ) {
500 $this->mFilteredExifData[
'GPSAltitude'] *= -1;
504 unset( $this->mFilteredExifData[
'GPSAltitude'] );
509 unset( $this->mFilteredExifData[
'GPSAltitudeRef'] );
511 $this->exifPropToOrd(
'FileSource' );
512 $this->exifPropToOrd(
'SceneType' );
514 $this->charCodeString(
'UserComment' );
515 $this->charCodeString(
'GPSProcessingMethod' );
516 $this->charCodeString(
'GPSAreaInformation' );
521 if ( isset( $this->mFilteredExifData[
'ComponentsConfiguration'] ) ) {
522 $val = $this->mFilteredExifData[
'ComponentsConfiguration'];
525 $strLen = strlen( $val );
526 for ( $i = 0; $i < $strLen; $i++ ) {
527 $ccVals[$i] = ord( substr( $val, $i, 1 ) );
530 $ccVals[
'_type'] =
'ol';
531 $this->mFilteredExifData[
'ComponentsConfiguration'] = $ccVals;
541 if ( isset( $this->mFilteredExifData[
'GPSVersion'] ) ) {
542 $val = $this->mFilteredExifData[
'GPSVersion'];
545 $strLen = strlen( $val );
546 for ( $i = 0; $i < $strLen; $i++ ) {
550 $newVal .= ord( substr( $val, $i, 1 ) );
553 if ( $this->byteOrder ===
'LE' ) {
556 for ( $i = strlen( $newVal ) - 1; $i >= 0; $i-- ) {
557 $newVal2 .= substr( $newVal, $i, 1 );
559 $this->mFilteredExifData[
'GPSVersionID'] = $newVal2;
561 $this->mFilteredExifData[
'GPSVersionID'] = $newVal;
563 unset( $this->mFilteredExifData[
'GPSVersion'] );
573 private function charCodeString( $prop ) {
574 if ( isset( $this->mFilteredExifData[$prop] ) ) {
575 if ( strlen( $this->mFilteredExifData[$prop] ) <= 8 ) {
578 $this->debug( $this->mFilteredExifData[$prop], __FUNCTION__,
false );
579 unset( $this->mFilteredExifData[$prop] );
583 $charCode = substr( $this->mFilteredExifData[$prop], 0, 8 );
584 $val = substr( $this->mFilteredExifData[$prop], 8 );
586 switch ( $charCode ) {
587 case "JIS\x00\x00\x00\x00\x00":
588 $charset =
"Shift-JIS";
591 $charset =
"UTF-16" . $this->byteOrder;
599 AtEase::suppressWarnings();
600 $val = iconv( $charset,
'UTF-8//IGNORE', $val );
601 AtEase::restoreWarnings();
605 UtfNormal\Validator::quickIsNFCVerify( $valCopy );
606 if ( $valCopy !== $val ) {
607 AtEase::suppressWarnings();
608 $val = iconv(
'Windows-1252',
'UTF-8//IGNORE', $val );
609 AtEase::restoreWarnings();
615 if ( strlen( $val ) === 0 ) {
617 $this->debug( $this->mFilteredExifData[$prop], __FUNCTION__,
"$prop: Is only whitespace" );
618 unset( $this->mFilteredExifData[$prop] );
624 $this->mFilteredExifData[$prop] = $val;
634 private function exifPropToOrd( $prop ) {
635 if ( isset( $this->mFilteredExifData[$prop] ) ) {
636 $this->mFilteredExifData[$prop] = ord( $this->mFilteredExifData[$prop] );
645 private function exifGPStoNumber( $prop ) {
646 $loc =& $this->mFilteredExifData[$prop];
647 $dir =& $this->mFilteredExifData[$prop .
'Ref'];
650 if ( isset( $loc ) && isset( $dir )
651 && ( $dir ===
'N' || $dir ===
'S' || $dir ===
'E' || $dir ===
'W' )
653 [ $num, $denom ] = explode(
'/', $loc[0], 2 );
654 $res = (int)$num / (
int)$denom;
655 [ $num, $denom ] = explode(
'/', $loc[1], 2 );
656 $res += ( (int)$num / (
int)$denom ) * ( 1 / 60 );
657 [ $num, $denom ] = explode(
'/', $loc[2], 2 );
658 $res += ( (int)$num / (
int)$denom ) * ( 1 / 3600 );
660 if ( $dir ===
'S' || $dir ===
'W' ) {
669 if (
$res !==
false ) {
670 $this->mFilteredExifData[$prop] =
$res;
673 unset( $this->mFilteredExifData[$prop] );
675 unset( $this->mFilteredExifData[$prop .
'Ref'] );
689 return $this->mRawExifData;
697 return $this->mFilteredExifData;
724 private function isByte( $in ) {
725 if ( !is_array( $in ) && sprintf(
'%d', $in ) == $in && $in >= 0 && $in <= 255 ) {
726 $this->debug( $in, __FUNCTION__,
true );
731 $this->debug( $in, __FUNCTION__,
false );
740 private function isASCII( $in ) {
741 if ( is_array( $in ) ) {
745 if ( preg_match(
"/[^\x0a\x20-\x7e]/", $in ) ) {
746 $this->debug( $in, __FUNCTION__,
'found a character that is not allowed' );
751 if ( preg_match(
'/^\s*$/', $in ) ) {
752 $this->debug( $in, __FUNCTION__,
'input consisted solely of whitespace' );
764 private function isShort( $in ) {
765 if ( !is_array( $in ) && sprintf(
'%d', $in ) == $in && $in >= 0 && $in <= 65536 ) {
766 $this->debug( $in, __FUNCTION__,
true );
771 $this->debug( $in, __FUNCTION__,
false );
780 private function isLong( $in ) {
781 if ( !is_array( $in ) && sprintf(
'%d', $in ) == $in && $in >= 0 && $in <= 4294967296 ) {
782 $this->debug( $in, __FUNCTION__,
true );
787 $this->debug( $in, __FUNCTION__,
false );
796 private function isRational( $in ) {
799 # Avoid division by zero
800 if ( !is_array( $in )
801 && preg_match(
'/^(\d+)\/(\d+[1-9]|[1-9]\d*)$/', $in, $m )
803 return $this->isLong( $m[1] ) && $this->isLong( $m[2] );
806 $this->debug( $in, __FUNCTION__,
'fed a non-fraction value' );
815 private function isUndefined( $in ) {
816 $this->debug( $in, __FUNCTION__,
true );
825 private function isSlong( $in ) {
826 if ( $this->isLong( abs( (
float)$in ) ) ) {
827 $this->debug( $in, __FUNCTION__,
true );
832 $this->debug( $in, __FUNCTION__,
false );
841 private function isSrational( $in ) {
844 # Avoid division by zero
845 if ( !is_array( $in ) &&
846 preg_match(
'/^(-?\d+)\/(\d+[1-9]|[1-9]\d*)$/', $in, $m )
848 return $this->isSlong( $m[0] ) && $this->isSlong( $m[1] );
851 $this->debug( $in, __FUNCTION__,
'fed a non-fraction value' );
867 private function validate( $section, $tag, $val, $recursive =
false ): bool {
868 $debug =
"tag is '$tag'";
869 $etype = $this->mExifTags[$section][$tag];
871 if ( is_array( $etype ) ) {
872 [ $etype, $ecount ] = $etype;
880 if ( is_array( $val ) ) {
881 $count = count( $val );
882 if ( $ecount !== $count ) {
883 $this->debug( $val, __FUNCTION__,
"Expected $ecount elements for $tag but got $count" );
889 foreach ( $val as $v ) {
890 if ( !$this->validate( $section, $tag, $v,
true ) ) {
899 if ( $val ===
null ) {
904 switch ( (
string)$etype ) {
905 case (
string)self::BYTE:
906 $this->debug( $val, __FUNCTION__, $debug );
908 return $this->isByte( $val );
909 case (
string)self::ASCII:
910 $this->debug( $val, __FUNCTION__, $debug );
912 return $this->isASCII( $val );
913 case (
string)self::SHORT:
914 $this->debug( $val, __FUNCTION__, $debug );
916 return $this->isShort( $val );
917 case (
string)self::LONG:
918 $this->debug( $val, __FUNCTION__, $debug );
920 return $this->isLong( $val );
921 case (
string)self::RATIONAL:
922 $this->debug( $val, __FUNCTION__, $debug );
924 return $this->isRational( $val );
925 case (
string)self::SHORT_OR_LONG:
926 $this->debug( $val, __FUNCTION__, $debug );
928 return $this->isShort( $val ) || $this->isLong( $val );
929 case (
string)self::UNDEFINED:
930 $this->debug( $val, __FUNCTION__, $debug );
932 return $this->isUndefined( $val );
933 case (
string)self::SLONG:
934 $this->debug( $val, __FUNCTION__, $debug );
936 return $this->isSlong( $val );
937 case (
string)self::SRATIONAL:
938 $this->debug( $val, __FUNCTION__, $debug );
940 return $this->isSrational( $val );
941 case (
string)self::IGNORE:
942 $this->debug( $val, __FUNCTION__, $debug );
946 $this->debug( $val, __FUNCTION__,
"The tag '$tag' is unknown" );
959 private function debug( $in, $fname, $action =
null ) {
963 $type = gettype( $in );
964 $class = ucfirst( __CLASS__ );
965 if ( is_array( $in ) ) {
966 $in = print_r( $in,
true );
969 if ( $action ===
true ) {
970 wfDebugLog( $this->log,
"$class::$fname: accepted: '$in' (type: $type)" );
971 } elseif ( $action ===
false ) {
972 wfDebugLog( $this->log,
"$class::$fname: rejected: '$in' (type: $type)" );
973 } elseif ( $action ===
null ) {
974 wfDebugLog( $this->log,
"$class::$fname: input was: '$in' (type: $type)" );
976 wfDebugLog( $this->log,
"$class::$fname: $action (type: $type; content: '$in')" );
986 private function debugFile( $fname, $io ) {
990 $class = ucfirst( __CLASS__ );
992 wfDebugLog( $this->log,
"$class::$fname: begin processing: '{$this->basename}'" );
994 wfDebugLog( $this->log,
"$class::$fname: end processing: '{$this->basename}'" );