24 private const BYTE = 1;
29 private const ASCII = 2;
32 private const SHORT = 3;
35 private const LONG = 4;
40 private const RATIONAL = 5;
43 private const SHORT_OR_LONG = 6;
46 private const UNDEFINED = 7;
49 private const SLONG = 9;
54 private const SRATIONAL = 10;
57 private const IGNORE = -1;
66 private $mRawExifData;
72 private $mFilteredExifData;
98 if ( !function_exists(
'exif_read_data' ) ) {
100 "Internal error: exif_read_data not present. " .
101 "\$wgShowEXIF may be incorrectly set or not checked by an extension."
114 # TIFF Rev. 6.0 Attribute Information (p22)
116 # Tags relating to image structure
118 'ImageWidth' => self::SHORT_OR_LONG,
120 'ImageLength' => self::SHORT_OR_LONG,
121 # Number of bits per component
122 'BitsPerSample' => [ self::SHORT, 3 ],
124 # "When a primary image is JPEG compressed, this designation is not"
125 # "necessary and is omitted." (p23)
126 # Compression scheme #p23
127 'Compression' => self::SHORT,
128 # Pixel composition #p23
129 'PhotometricInterpretation' => self::SHORT,
130 # Orientation of image #p24
131 'Orientation' => self::SHORT,
132 # Number of components
133 'SamplesPerPixel' => self::SHORT,
134 # Image data arrangement #p24
135 'PlanarConfiguration' => self::SHORT,
136 # Subsampling ratio of Y to C #p24
137 'YCbCrSubSampling' => [ self::SHORT, 2 ],
138 # Y and C positioning #p24-25
139 'YCbCrPositioning' => self::SHORT,
140 # Image resolution in width direction
141 'XResolution' => self::RATIONAL,
142 # Image resolution in height direction
143 'YResolution' => self::RATIONAL,
144 # Unit of X and Y resolution #(p26)
145 'ResolutionUnit' => self::SHORT,
147 # Tags relating to recording offset
148 # Image data location
149 'StripOffsets' => self::SHORT_OR_LONG,
150 # Number of rows per strip
151 'RowsPerStrip' => self::SHORT_OR_LONG,
152 # Bytes per compressed strip
153 'StripByteCounts' => self::SHORT_OR_LONG,
155 'JPEGInterchangeFormat' => self::SHORT_OR_LONG,
157 'JPEGInterchangeFormatLength' => self::SHORT_OR_LONG,
159 # Tags relating to image data characteristics
161 'TransferFunction' => self::IGNORE,
162 # White point chromaticity
163 'WhitePoint' => [ self::RATIONAL, 2 ],
164 # Chromaticities of primarities
165 'PrimaryChromaticities' => [ self::RATIONAL, 6 ],
166 # Color space transformation matrix coefficients #p27
167 'YCbCrCoefficients' => [ self::RATIONAL, 3 ],
168 # Pair of black and white reference values
169 'ReferenceBlackWhite' => [ self::RATIONAL, 6 ],
172 # File change date and time
173 'DateTime' => self::ASCII,
175 'ImageDescription' => self::ASCII,
176 # Image input equipment manufacturer
177 'Make' => self::ASCII,
178 # Image input equipment model
179 'Model' => self::ASCII,
181 'Software' => self::ASCII,
182 # Person who created the image
183 'Artist' => self::ASCII,
185 'Copyright' => self::ASCII,
188 # Exif IFD Attribute Information (p30-31)
190 # @todo NOTE: Nonexistence of this field is taken to mean non-conformance
191 # to the Exif 2.1 AND 2.2 standards
192 'ExifVersion' => self::UNDEFINED,
193 # Supported Flashpix version #p32
194 'FlashPixVersion' => self::UNDEFINED,
196 # Tags relating to Image Data Characteristics
197 # Color space information #p32
198 'ColorSpace' => self::SHORT,
200 # Tags relating to image configuration
201 # Meaning of each component #p33
202 'ComponentsConfiguration' => self::UNDEFINED,
203 # Image compression mode
204 'CompressedBitsPerPixel' => self::RATIONAL,
206 'PixelYDimension' => self::SHORT_OR_LONG,
208 'PixelXDimension' => self::SHORT_OR_LONG,
210 # Tags relating to related user information
212 'MakerNote' => self::IGNORE,
214 'UserComment' => self::UNDEFINED,
216 # Tags relating to related file information
218 'RelatedSoundFile' => self::ASCII,
220 # Tags relating to date and time
221 # Date and time of original data generation #p36
222 'DateTimeOriginal' => self::ASCII,
223 # Date and time of original data generation
224 'DateTimeDigitized' => self::ASCII,
225 # DateTime subseconds
226 'SubSecTime' => self::ASCII,
227 # DateTimeOriginal subseconds
228 'SubSecTimeOriginal' => self::ASCII,
229 # DateTimeDigitized subseconds
230 'SubSecTimeDigitized' => self::ASCII,
232 # Tags relating to picture-taking conditions (p31)
234 'ExposureTime' => self::RATIONAL,
236 'FNumber' => self::RATIONAL,
237 # Exposure Program #p38
238 'ExposureProgram' => self::SHORT,
239 # Spectral sensitivity
240 'SpectralSensitivity' => self::ASCII,
242 'ISOSpeedRatings' => self::SHORT,
244 # Optoelectronic conversion factor. Note: We don't have support for this atm.
245 'OECF' => self::IGNORE,
248 'ShutterSpeedValue' => self::SRATIONAL,
250 'ApertureValue' => self::RATIONAL,
252 'BrightnessValue' => self::SRATIONAL,
254 'ExposureBiasValue' => self::SRATIONAL,
255 # Maximum land aperture
256 'MaxApertureValue' => self::RATIONAL,
258 'SubjectDistance' => self::RATIONAL,
260 'MeteringMode' => self::SHORT,
261 # Light source #p40-41
262 'LightSource' => self::SHORT,
264 'Flash' => self::SHORT,
266 'FocalLength' => self::RATIONAL,
268 'SubjectArea' => [ self::SHORT, 4 ],
270 'FlashEnergy' => self::RATIONAL,
271 # Spatial frequency response. Not supported atm.
272 'SpatialFrequencyResponse' => self::IGNORE,
273 # Focal plane X resolution
274 'FocalPlaneXResolution' => self::RATIONAL,
275 # Focal plane Y resolution
276 'FocalPlaneYResolution' => self::RATIONAL,
277 # Focal plane resolution unit #p46
278 'FocalPlaneResolutionUnit' => self::SHORT,
280 'SubjectLocation' => [ self::SHORT, 2 ],
282 'ExposureIndex' => self::RATIONAL,
283 # Sensing method #p46
284 'SensingMethod' => self::SHORT,
286 'FileSource' => self::UNDEFINED,
288 'SceneType' => self::UNDEFINED,
289 # CFA pattern. not supported atm.
290 'CFAPattern' => self::IGNORE,
291 # Custom image processing #p48
292 'CustomRendered' => self::SHORT,
294 'ExposureMode' => self::SHORT,
296 'WhiteBalance' => self::SHORT,
298 'DigitalZoomRatio' => self::RATIONAL,
299 # Focal length in 35 mm film
300 'FocalLengthIn35mmFilm' => self::SHORT,
301 # Scene capture type #p49
302 'SceneCaptureType' => self::SHORT,
303 # Scene control #p49-50
304 'GainControl' => self::SHORT,
306 'Contrast' => self::SHORT,
308 'Saturation' => self::SHORT,
310 'Sharpness' => self::SHORT,
312 # Device settings description. This could maybe be supported. Need to find an
313 # example file that uses this to see if it has stuff of interest in it.
314 'DeviceSettingDescription' => self::IGNORE,
316 # Subject distance range #p51
317 'SubjectDistanceRange' => self::SHORT,
320 'ImageUniqueID' => self::ASCII,
323 # GPS Attribute Information (p52)
325 'GPSVersion' => self::UNDEFINED,
326 # Should be an array of 4 Exif::BYTE's. However, php treats it as an undefined
327 # Note exif standard calls this GPSVersionID, but php doesn't like the id suffix
328 # North or South Latitude #p52-53
329 'GPSLatitudeRef' => self::ASCII,
331 'GPSLatitude' => [ self::RATIONAL, 3 ],
332 # East or West Longitude #p53
333 'GPSLongitudeRef' => self::ASCII,
335 'GPSLongitude' => [ self::RATIONAL, 3 ],
336 'GPSAltitudeRef' => self::UNDEFINED,
338 # Altitude reference. Note, the exif standard says this should be an EXIF::Byte,
339 # but php seems to disagree.
341 'GPSAltitude' => self::RATIONAL,
342 # GPS time (atomic clock)
343 'GPSTimeStamp' => [ self::RATIONAL, 3 ],
344 # Satellites used for measurement
345 'GPSSatellites' => self::ASCII,
346 # Receiver status #p54
347 'GPSStatus' => self::ASCII,
348 # Measurement mode #p54-55
349 'GPSMeasureMode' => self::ASCII,
350 # Measurement precision
351 'GPSDOP' => self::RATIONAL,
353 'GPSSpeedRef' => self::ASCII,
354 # Speed of GPS receiver
355 'GPSSpeed' => self::RATIONAL,
356 # Reference for direction of movement #p55
357 'GPSTrackRef' => self::ASCII,
358 # Direction of movement
359 'GPSTrack' => self::RATIONAL,
360 # Reference for direction of image #p56
361 'GPSImgDirectionRef' => self::ASCII,
363 'GPSImgDirection' => self::RATIONAL,
364 # Geodetic survey data used
365 'GPSMapDatum' => self::ASCII,
366 # Reference for latitude of destination #p56
367 'GPSDestLatitudeRef' => self::ASCII,
368 # Latitude destination
369 'GPSDestLatitude' => [ self::RATIONAL, 3 ],
370 # Reference for longitude of destination #p57
371 'GPSDestLongitudeRef' => self::ASCII,
372 # Longitude of destination
373 'GPSDestLongitude' => [ self::RATIONAL, 3 ],
374 # Reference for bearing of destination #p57
375 'GPSDestBearingRef' => self::ASCII,
376 # Bearing of destination
377 'GPSDestBearing' => self::RATIONAL,
378 # Reference for distance to destination #p57-58
379 'GPSDestDistanceRef' => self::ASCII,
380 # Distance to destination
381 'GPSDestDistance' => self::RATIONAL,
382 # Name of GPS processing method
383 'GPSProcessingMethod' => self::UNDEFINED,
385 'GPSAreaInformation' => self::UNDEFINED,
387 'GPSDateStamp' => self::ASCII,
388 # GPS differential correction
389 'GPSDifferential' => self::SHORT,
395 if ( $byteOrder ===
'BE' || $byteOrder ===
'LE' ) {
396 $this->byteOrder = $byteOrder;
401 wfWarn(
'Exif class did not have byte order specified. ' .
402 'Some properties may be decoded incorrectly.' );
404 $this->byteOrder =
'BE';
407 $this->debugFile( __FUNCTION__,
true );
410 $data = @exif_read_data( $this->file,
'',
true );
417 $this->mRawExifData = $data ?: [];
418 $this->makeFilteredData();
419 $this->collapseData();
420 $this->debugFile( __FUNCTION__,
false );
426 private function makeFilteredData() {
427 $this->mFilteredExifData = [];
429 foreach ( $this->mRawExifData as $section => $data ) {
430 if ( !array_key_exists( $section, $this->mExifTags ) ) {
431 $this->debug( $section, __FUNCTION__,
"'$section' is not a valid Exif section" );
435 foreach ( $data as $tag => $value ) {
436 if ( !array_key_exists( $tag, $this->mExifTags[$section] ) ) {
437 $this->debug( $tag, __FUNCTION__,
"'$tag' is not a valid tag in '$section'" );
441 if ( $this->validate( $section, $tag, $value ) ) {
444 $this->mFilteredExifData[$tag] = $value;
446 $this->debug( $value, __FUNCTION__,
"'$tag' contained invalid data" );
470 private function collapseData() {
471 $this->exifGPStoNumber(
'GPSLatitude' );
472 $this->exifGPStoNumber(
'GPSDestLatitude' );
473 $this->exifGPStoNumber(
'GPSLongitude' );
474 $this->exifGPStoNumber(
'GPSDestLongitude' );
476 if ( isset( $this->mFilteredExifData[
'GPSAltitude'] ) ) {
480 [ $num, $denom ] = explode(
'/', $this->mFilteredExifData[
'GPSAltitude'], 2 );
481 $this->mFilteredExifData[
'GPSAltitude'] = (int)$num / (
int)$denom;
483 if ( isset( $this->mFilteredExifData[
'GPSAltitudeRef'] ) ) {
484 switch ( $this->mFilteredExifData[
'GPSAltitudeRef'] ) {
490 $this->mFilteredExifData[
'GPSAltitude'] *= -1;
494 unset( $this->mFilteredExifData[
'GPSAltitude'] );
499 unset( $this->mFilteredExifData[
'GPSAltitudeRef'] );
501 $this->exifPropToOrd(
'FileSource' );
502 $this->exifPropToOrd(
'SceneType' );
504 $this->charCodeString(
'UserComment' );
505 $this->charCodeString(
'GPSProcessingMethod' );
506 $this->charCodeString(
'GPSAreaInformation' );
511 if ( isset( $this->mFilteredExifData[
'ComponentsConfiguration'] ) ) {
512 $val = $this->mFilteredExifData[
'ComponentsConfiguration'];
515 $strLen = strlen( $val );
516 for ( $i = 0; $i < $strLen; $i++ ) {
517 $ccVals[$i] = ord( substr( $val, $i, 1 ) );
520 $ccVals[
'_type'] =
'ol';
521 $this->mFilteredExifData[
'ComponentsConfiguration'] = $ccVals;
531 if ( isset( $this->mFilteredExifData[
'GPSVersion'] ) ) {
532 $val = $this->mFilteredExifData[
'GPSVersion'];
535 $strLen = strlen( $val );
536 for ( $i = 0; $i < $strLen; $i++ ) {
540 $newVal .= ord( substr( $val, $i, 1 ) );
543 if ( $this->byteOrder ===
'LE' ) {
546 for ( $i = strlen( $newVal ) - 1; $i >= 0; $i-- ) {
547 $newVal2 .= substr( $newVal, $i, 1 );
549 $this->mFilteredExifData[
'GPSVersionID'] = $newVal2;
551 $this->mFilteredExifData[
'GPSVersionID'] = $newVal;
553 unset( $this->mFilteredExifData[
'GPSVersion'] );
563 private function charCodeString( $prop ) {
564 if ( isset( $this->mFilteredExifData[$prop] ) ) {
565 if ( strlen( $this->mFilteredExifData[$prop] ) <= 8 ) {
568 $this->debug( $this->mFilteredExifData[$prop], __FUNCTION__,
false );
569 unset( $this->mFilteredExifData[$prop] );
573 $charCode = substr( $this->mFilteredExifData[$prop], 0, 8 );
574 $val = substr( $this->mFilteredExifData[$prop], 8 );
576 $charset = match ( $charCode ) {
577 "JIS\x00\x00\x00\x00\x00" =>
'Shift-JIS',
578 "UNICODE\x00" =>
'UTF-16' . $this->byteOrder,
583 $val = @iconv( $charset,
'UTF-8//IGNORE', $val );
587 \UtfNormal\Validator::quickIsNFCVerify( $valCopy );
588 if ( $valCopy !== $val ) {
590 $val = @iconv(
'Windows-1252',
'UTF-8//IGNORE', $val );
598 $this->debug( $this->mFilteredExifData[$prop], __FUNCTION__,
"$prop: Is only whitespace" );
599 unset( $this->mFilteredExifData[$prop] );
605 $this->mFilteredExifData[$prop] = $val;
615 private function exifPropToOrd( $prop ) {
616 if ( isset( $this->mFilteredExifData[$prop] ) ) {
617 $this->mFilteredExifData[$prop] = ord( $this->mFilteredExifData[$prop] );
626 private function exifGPStoNumber( $prop ) {
627 $loc = $this->mFilteredExifData[$prop] ??
null;
628 $dir = $this->mFilteredExifData[$prop .
'Ref'] ??
null;
631 if ( $loc !==
null && in_array( $dir, [
'N',
'S',
'E',
'W' ] ) ) {
632 if ( is_array( $loc ) && count( $loc ) === 3 ) {
633 [ $num, $denom ] = explode(
'/', $loc[0], 2 );
634 $res = (int)$num / (
int)$denom;
635 [ $num, $denom ] = explode(
'/', $loc[1], 2 );
636 $res += ( (int)$num / (
int)$denom ) * ( 1 / 60 );
637 [ $num, $denom ] = explode(
'/', $loc[2], 2 );
638 $res += ( (int)$num / (
int)$denom ) * ( 1 / 3600 );
639 } elseif ( is_string( $loc ) ) {
641 [ $num, $denom ] = explode(
'/', $loc, 2 );
642 $res = (int)$num / (
int)$denom;
645 if ( $res && ( $dir ===
'S' || $dir ===
'W' ) ) {
654 if ( $res !==
false ) {
655 $this->mFilteredExifData[$prop] = $res;
658 unset( $this->mFilteredExifData[$prop] );
660 unset( $this->mFilteredExifData[$prop .
'Ref'] );
668 return $this->mRawExifData;
676 return $this->mFilteredExifData;
701 private function isByte( $in ) {
702 if ( !is_array( $in ) && sprintf(
'%d', $in ) == $in && $in >= 0 && $in <= 255 ) {
703 $this->debug( $in, __FUNCTION__,
true );
708 $this->debug( $in, __FUNCTION__,
false );
717 private function isASCII( $in ) {
718 if ( is_array( $in ) ) {
722 if ( preg_match(
"/[^\x0a\x20-\x7e]/", $in ) ) {
723 $this->debug( $in, __FUNCTION__,
'found a character that is not allowed' );
728 if ( preg_match(
'/^\s*$/', $in ) ) {
729 $this->debug( $in, __FUNCTION__,
'input consisted solely of whitespace' );
741 private function isShort( $in ) {
742 if ( !is_array( $in ) && sprintf(
'%d', $in ) == $in && $in >= 0 && $in <= 65536 ) {
743 $this->debug( $in, __FUNCTION__,
true );
748 $this->debug( $in, __FUNCTION__,
false );
757 private function isLong( $in ) {
758 if ( !is_array( $in ) && sprintf(
'%d', $in ) == $in && $in >= 0 && $in <= 4_294_967_296 ) {
759 $this->debug( $in, __FUNCTION__,
true );
764 $this->debug( $in, __FUNCTION__,
false );
773 private function isRational( $in ) {
776 # Avoid division by zero
777 if ( !is_array( $in )
778 && preg_match(
'/^(\d+)\/(\d+[1-9]|[1-9]\d*)$/', $in, $m )
780 return $this->isLong( $m[1] ) && $this->isLong( $m[2] );
783 $this->debug( $in, __FUNCTION__,
'fed a non-fraction value' );
792 private function isUndefined( $in ) {
793 $this->debug( $in, __FUNCTION__,
true );
802 private function isSlong( $in ) {
803 if ( $this->isLong( abs( (
float)$in ) ) ) {
804 $this->debug( $in, __FUNCTION__,
true );
809 $this->debug( $in, __FUNCTION__,
false );
818 private function isSrational( $in ) {
821 # Avoid division by zero
822 if ( !is_array( $in ) &&
823 preg_match(
'/^(-?\d+)\/(\d+[1-9]|[1-9]\d*)$/', $in, $m )
825 return $this->isSlong( $m[0] ) && $this->isSlong( $m[1] );
828 $this->debug( $in, __FUNCTION__,
'fed a non-fraction value' );
842 private function validate( $section, $tag, $val, $recursive =
false ): bool {
843 $debug =
"tag is '$tag'";
844 $etype = $this->mExifTags[$section][$tag];
846 if ( is_array( $etype ) ) {
847 [ $etype, $ecount ] = $etype;
855 if ( is_array( $val ) ) {
856 $count = count( $val );
857 if ( $ecount !== $count ) {
858 $this->debug( $val, __FUNCTION__,
"Expected $ecount elements for $tag but got $count" );
864 foreach ( $val as $v ) {
865 if ( !$this->validate( $section, $tag, $v,
true ) ) {
874 if ( $val ===
null ) {
879 switch ( (
string)$etype ) {
880 case (
string)self::BYTE:
881 $this->debug( $val, __FUNCTION__, $debug );
883 return $this->isByte( $val );
884 case (
string)self::ASCII:
885 $this->debug( $val, __FUNCTION__, $debug );
887 return $this->isASCII( $val );
888 case (
string)self::SHORT:
889 $this->debug( $val, __FUNCTION__, $debug );
891 return $this->isShort( $val );
892 case (
string)self::LONG:
893 $this->debug( $val, __FUNCTION__, $debug );
895 return $this->isLong( $val );
896 case (
string)self::RATIONAL:
897 $this->debug( $val, __FUNCTION__, $debug );
899 return $this->isRational( $val );
900 case (
string)self::SHORT_OR_LONG:
901 $this->debug( $val, __FUNCTION__, $debug );
903 return $this->isShort( $val ) || $this->isLong( $val );
904 case (
string)self::UNDEFINED:
905 $this->debug( $val, __FUNCTION__, $debug );
907 return $this->isUndefined( $val );
908 case (
string)self::SLONG:
909 $this->debug( $val, __FUNCTION__, $debug );
911 return $this->isSlong( $val );
912 case (
string)self::SRATIONAL:
913 $this->debug( $val, __FUNCTION__, $debug );
915 return $this->isSrational( $val );
916 case (
string)self::IGNORE:
917 $this->debug( $val, __FUNCTION__, $debug );
921 $this->debug( $val, __FUNCTION__,
"The tag '$tag' is unknown" );
934 private function debug( $in, $fname, $action =
null ) {
938 $type = get_debug_type( $in );
939 $class = ucfirst( __CLASS__ );
940 if ( is_array( $in ) ) {
941 $in = print_r( $in,
true );
944 if ( $action ===
true ) {
945 wfDebugLog( $this->log,
"$class::$fname: accepted: '$in' (type: $type)" );
946 } elseif ( $action ===
false ) {
947 wfDebugLog( $this->log,
"$class::$fname: rejected: '$in' (type: $type)" );
948 } elseif ( $action ===
null ) {
949 wfDebugLog( $this->log,
"$class::$fname: input was: '$in' (type: $type)" );
951 wfDebugLog( $this->log,
"$class::$fname: $action (type: $type; content: '$in')" );
961 private function debugFile( $fname, $io ) {
965 $class = ucfirst( __CLASS__ );
967 wfDebugLog( $this->log,
"$class::$fname: begin processing: '{$this->basename}'" );
969 wfDebugLog( $this->log,
"$class::$fname: end processing: '{$this->basename}'" );