23 private const BYTE = 1;
28 private const ASCII = 2;
31 private const SHORT = 3;
34 private const LONG = 4;
39 private const RATIONAL = 5;
42 private const SHORT_OR_LONG = 6;
45 private const UNDEFINED = 7;
48 private const SLONG = 9;
53 private const SRATIONAL = 10;
56 private const IGNORE = -1;
65 private $mRawExifData;
71 private $mFilteredExifData;
97 if ( !function_exists(
'exif_read_data' ) ) {
99 "Internal error: exif_read_data not present. " .
100 "\$wgShowEXIF may be incorrectly set or not checked by an extension."
113 # TIFF Rev. 6.0 Attribute Information (p22)
115 # Tags relating to image structure
117 'ImageWidth' => self::SHORT_OR_LONG,
119 'ImageLength' => self::SHORT_OR_LONG,
120 # Number of bits per component
121 'BitsPerSample' => [ self::SHORT, 3 ],
123 # "When a primary image is JPEG compressed, this designation is not"
124 # "necessary and is omitted." (p23)
125 # Compression scheme #p23
126 'Compression' => self::SHORT,
127 # Pixel composition #p23
128 'PhotometricInterpretation' => self::SHORT,
129 # Orientation of image #p24
130 'Orientation' => self::SHORT,
131 # Number of components
132 'SamplesPerPixel' => self::SHORT,
133 # Image data arrangement #p24
134 'PlanarConfiguration' => self::SHORT,
135 # Subsampling ratio of Y to C #p24
136 'YCbCrSubSampling' => [ self::SHORT, 2 ],
137 # Y and C positioning #p24-25
138 'YCbCrPositioning' => self::SHORT,
139 # Image resolution in width direction
140 'XResolution' => self::RATIONAL,
141 # Image resolution in height direction
142 'YResolution' => self::RATIONAL,
143 # Unit of X and Y resolution #(p26)
144 'ResolutionUnit' => self::SHORT,
146 # Tags relating to recording offset
147 # Image data location
148 'StripOffsets' => self::SHORT_OR_LONG,
149 # Number of rows per strip
150 'RowsPerStrip' => self::SHORT_OR_LONG,
151 # Bytes per compressed strip
152 'StripByteCounts' => self::SHORT_OR_LONG,
154 'JPEGInterchangeFormat' => self::SHORT_OR_LONG,
156 'JPEGInterchangeFormatLength' => self::SHORT_OR_LONG,
158 # Tags relating to image data characteristics
160 'TransferFunction' => self::IGNORE,
161 # White point chromaticity
162 'WhitePoint' => [ self::RATIONAL, 2 ],
163 # Chromaticities of primarities
164 'PrimaryChromaticities' => [ self::RATIONAL, 6 ],
165 # Color space transformation matrix coefficients #p27
166 'YCbCrCoefficients' => [ self::RATIONAL, 3 ],
167 # Pair of black and white reference values
168 'ReferenceBlackWhite' => [ self::RATIONAL, 6 ],
171 # File change date and time
172 'DateTime' => self::ASCII,
174 'ImageDescription' => self::ASCII,
175 # Image input equipment manufacturer
176 'Make' => self::ASCII,
177 # Image input equipment model
178 'Model' => self::ASCII,
180 'Software' => self::ASCII,
181 # Person who created the image
182 'Artist' => self::ASCII,
184 'Copyright' => self::ASCII,
187 # Exif IFD Attribute Information (p30-31)
189 # @todo NOTE: Nonexistence of this field is taken to mean non-conformance
190 # to the Exif 2.1 AND 2.2 standards
191 'ExifVersion' => self::UNDEFINED,
192 # Supported Flashpix version #p32
193 'FlashPixVersion' => self::UNDEFINED,
195 # Tags relating to Image Data Characteristics
196 # Color space information #p32
197 'ColorSpace' => self::SHORT,
199 # Tags relating to image configuration
200 # Meaning of each component #p33
201 'ComponentsConfiguration' => self::UNDEFINED,
202 # Image compression mode
203 'CompressedBitsPerPixel' => self::RATIONAL,
205 'PixelYDimension' => self::SHORT_OR_LONG,
207 'PixelXDimension' => self::SHORT_OR_LONG,
209 # Tags relating to related user information
211 'MakerNote' => self::IGNORE,
213 'UserComment' => self::UNDEFINED,
215 # Tags relating to related file information
217 'RelatedSoundFile' => self::ASCII,
219 # Tags relating to date and time
220 # Date and time of original data generation #p36
221 'DateTimeOriginal' => self::ASCII,
222 # Date and time of original data generation
223 'DateTimeDigitized' => self::ASCII,
224 # DateTime subseconds
225 'SubSecTime' => self::ASCII,
226 # DateTimeOriginal subseconds
227 'SubSecTimeOriginal' => self::ASCII,
228 # DateTimeDigitized subseconds
229 'SubSecTimeDigitized' => self::ASCII,
231 # Tags relating to picture-taking conditions (p31)
233 'ExposureTime' => self::RATIONAL,
235 'FNumber' => self::RATIONAL,
236 # Exposure Program #p38
237 'ExposureProgram' => self::SHORT,
238 # Spectral sensitivity
239 'SpectralSensitivity' => self::ASCII,
241 'ISOSpeedRatings' => self::SHORT,
243 # Optoelectronic conversion factor. Note: We don't have support for this atm.
244 'OECF' => self::IGNORE,
247 'ShutterSpeedValue' => self::SRATIONAL,
249 'ApertureValue' => self::RATIONAL,
251 'BrightnessValue' => self::SRATIONAL,
253 'ExposureBiasValue' => self::SRATIONAL,
254 # Maximum land aperture
255 'MaxApertureValue' => self::RATIONAL,
257 'SubjectDistance' => self::RATIONAL,
259 'MeteringMode' => self::SHORT,
260 # Light source #p40-41
261 'LightSource' => self::SHORT,
263 'Flash' => self::SHORT,
265 'FocalLength' => self::RATIONAL,
267 'SubjectArea' => [ self::SHORT, 4 ],
269 'FlashEnergy' => self::RATIONAL,
270 # Spatial frequency response. Not supported atm.
271 'SpatialFrequencyResponse' => self::IGNORE,
272 # Focal plane X resolution
273 'FocalPlaneXResolution' => self::RATIONAL,
274 # Focal plane Y resolution
275 'FocalPlaneYResolution' => self::RATIONAL,
276 # Focal plane resolution unit #p46
277 'FocalPlaneResolutionUnit' => self::SHORT,
279 'SubjectLocation' => [ self::SHORT, 2 ],
281 'ExposureIndex' => self::RATIONAL,
282 # Sensing method #p46
283 'SensingMethod' => self::SHORT,
285 'FileSource' => self::UNDEFINED,
287 'SceneType' => self::UNDEFINED,
288 # CFA pattern. not supported atm.
289 'CFAPattern' => self::IGNORE,
290 # Custom image processing #p48
291 'CustomRendered' => self::SHORT,
293 'ExposureMode' => self::SHORT,
295 'WhiteBalance' => self::SHORT,
297 'DigitalZoomRatio' => self::RATIONAL,
298 # Focal length in 35 mm film
299 'FocalLengthIn35mmFilm' => self::SHORT,
300 # Scene capture type #p49
301 'SceneCaptureType' => self::SHORT,
302 # Scene control #p49-50
303 'GainControl' => self::SHORT,
305 'Contrast' => self::SHORT,
307 'Saturation' => self::SHORT,
309 'Sharpness' => self::SHORT,
311 # Device settings description. This could maybe be supported. Need to find an
312 # example file that uses this to see if it has stuff of interest in it.
313 'DeviceSettingDescription' => self::IGNORE,
315 # Subject distance range #p51
316 'SubjectDistanceRange' => self::SHORT,
319 'ImageUniqueID' => self::ASCII,
322 # GPS Attribute Information (p52)
324 'GPSVersion' => self::UNDEFINED,
325 # Should be an array of 4 Exif::BYTE's. However, php treats it as an undefined
326 # Note exif standard calls this GPSVersionID, but php doesn't like the id suffix
327 # North or South Latitude #p52-53
328 'GPSLatitudeRef' => self::ASCII,
330 'GPSLatitude' => [ self::RATIONAL, 3 ],
331 # East or West Longitude #p53
332 'GPSLongitudeRef' => self::ASCII,
334 'GPSLongitude' => [ self::RATIONAL, 3 ],
335 'GPSAltitudeRef' => self::UNDEFINED,
337 # Altitude reference. Note, the exif standard says this should be an EXIF::Byte,
338 # but php seems to disagree.
340 'GPSAltitude' => self::RATIONAL,
341 # GPS time (atomic clock)
342 'GPSTimeStamp' => [ self::RATIONAL, 3 ],
343 # Satellites used for measurement
344 'GPSSatellites' => self::ASCII,
345 # Receiver status #p54
346 'GPSStatus' => self::ASCII,
347 # Measurement mode #p54-55
348 'GPSMeasureMode' => self::ASCII,
349 # Measurement precision
350 'GPSDOP' => self::RATIONAL,
352 'GPSSpeedRef' => self::ASCII,
353 # Speed of GPS receiver
354 'GPSSpeed' => self::RATIONAL,
355 # Reference for direction of movement #p55
356 'GPSTrackRef' => self::ASCII,
357 # Direction of movement
358 'GPSTrack' => self::RATIONAL,
359 # Reference for direction of image #p56
360 'GPSImgDirectionRef' => self::ASCII,
362 'GPSImgDirection' => self::RATIONAL,
363 # Geodetic survey data used
364 'GPSMapDatum' => self::ASCII,
365 # Reference for latitude of destination #p56
366 'GPSDestLatitudeRef' => self::ASCII,
367 # Latitude destination
368 'GPSDestLatitude' => [ self::RATIONAL, 3 ],
369 # Reference for longitude of destination #p57
370 'GPSDestLongitudeRef' => self::ASCII,
371 # Longitude of destination
372 'GPSDestLongitude' => [ self::RATIONAL, 3 ],
373 # Reference for bearing of destination #p57
374 'GPSDestBearingRef' => self::ASCII,
375 # Bearing of destination
376 'GPSDestBearing' => self::RATIONAL,
377 # Reference for distance to destination #p57-58
378 'GPSDestDistanceRef' => self::ASCII,
379 # Distance to destination
380 'GPSDestDistance' => self::RATIONAL,
381 # Name of GPS processing method
382 'GPSProcessingMethod' => self::UNDEFINED,
384 'GPSAreaInformation' => self::UNDEFINED,
386 'GPSDateStamp' => self::ASCII,
387 # GPS differential correction
388 'GPSDifferential' => self::SHORT,
394 if ( $byteOrder ===
'BE' || $byteOrder ===
'LE' ) {
395 $this->byteOrder = $byteOrder;
400 wfWarn(
'Exif class did not have byte order specified. ' .
401 'Some properties may be decoded incorrectly.' );
403 $this->byteOrder =
'BE';
406 $this->debugFile( __FUNCTION__,
true );
408 AtEase::suppressWarnings();
409 $data = exif_read_data( $this->file,
'',
true );
410 AtEase::restoreWarnings();
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,
582 AtEase::suppressWarnings();
583 $val = iconv( $charset,
'UTF-8//IGNORE', $val );
584 AtEase::restoreWarnings();
588 UtfNormal\Validator::quickIsNFCVerify( $valCopy );
589 if ( $valCopy !== $val ) {
590 AtEase::suppressWarnings();
591 $val = iconv(
'Windows-1252',
'UTF-8//IGNORE', $val );
592 AtEase::restoreWarnings();
600 $this->debug( $this->mFilteredExifData[$prop], __FUNCTION__,
"$prop: Is only whitespace" );
601 unset( $this->mFilteredExifData[$prop] );
607 $this->mFilteredExifData[$prop] = $val;
617 private function exifPropToOrd( $prop ) {
618 if ( isset( $this->mFilteredExifData[$prop] ) ) {
619 $this->mFilteredExifData[$prop] = ord( $this->mFilteredExifData[$prop] );
628 private function exifGPStoNumber( $prop ) {
629 $loc = $this->mFilteredExifData[$prop] ??
null;
630 $dir = $this->mFilteredExifData[$prop .
'Ref'] ??
null;
633 if ( $loc !==
null && in_array( $dir, [
'N',
'S',
'E',
'W' ] ) ) {
634 if ( is_array( $loc ) && count( $loc ) === 3 ) {
635 [ $num, $denom ] = explode(
'/', $loc[0], 2 );
636 $res = (int)$num / (
int)$denom;
637 [ $num, $denom ] = explode(
'/', $loc[1], 2 );
638 $res += ( (int)$num / (
int)$denom ) * ( 1 / 60 );
639 [ $num, $denom ] = explode(
'/', $loc[2], 2 );
640 $res += ( (int)$num / (
int)$denom ) * ( 1 / 3600 );
641 } elseif ( is_string( $loc ) ) {
643 [ $num, $denom ] = explode(
'/', $loc, 2 );
644 $res = (int)$num / (
int)$denom;
647 if ( $res && ( $dir ===
'S' || $dir ===
'W' ) ) {
656 if ( $res !==
false ) {
657 $this->mFilteredExifData[$prop] = $res;
660 unset( $this->mFilteredExifData[$prop] );
662 unset( $this->mFilteredExifData[$prop .
'Ref'] );
670 return $this->mRawExifData;
678 return $this->mFilteredExifData;
703 private function isByte( $in ) {
704 if ( !is_array( $in ) && sprintf(
'%d', $in ) == $in && $in >= 0 && $in <= 255 ) {
705 $this->debug( $in, __FUNCTION__,
true );
710 $this->debug( $in, __FUNCTION__,
false );
719 private function isASCII( $in ) {
720 if ( is_array( $in ) ) {
724 if ( preg_match(
"/[^\x0a\x20-\x7e]/", $in ) ) {
725 $this->debug( $in, __FUNCTION__,
'found a character that is not allowed' );
730 if ( preg_match(
'/^\s*$/', $in ) ) {
731 $this->debug( $in, __FUNCTION__,
'input consisted solely of whitespace' );
743 private function isShort( $in ) {
744 if ( !is_array( $in ) && sprintf(
'%d', $in ) == $in && $in >= 0 && $in <= 65536 ) {
745 $this->debug( $in, __FUNCTION__,
true );
750 $this->debug( $in, __FUNCTION__,
false );
759 private function isLong( $in ) {
760 if ( !is_array( $in ) && sprintf(
'%d', $in ) == $in && $in >= 0 && $in <= 4_294_967_296 ) {
761 $this->debug( $in, __FUNCTION__,
true );
766 $this->debug( $in, __FUNCTION__,
false );
775 private function isRational( $in ) {
778 # Avoid division by zero
779 if ( !is_array( $in )
780 && preg_match(
'/^(\d+)\/(\d+[1-9]|[1-9]\d*)$/', $in, $m )
782 return $this->isLong( $m[1] ) && $this->isLong( $m[2] );
785 $this->debug( $in, __FUNCTION__,
'fed a non-fraction value' );
794 private function isUndefined( $in ) {
795 $this->debug( $in, __FUNCTION__,
true );
804 private function isSlong( $in ) {
805 if ( $this->isLong( abs( (
float)$in ) ) ) {
806 $this->debug( $in, __FUNCTION__,
true );
811 $this->debug( $in, __FUNCTION__,
false );
820 private function isSrational( $in ) {
823 # Avoid division by zero
824 if ( !is_array( $in ) &&
825 preg_match(
'/^(-?\d+)\/(\d+[1-9]|[1-9]\d*)$/', $in, $m )
827 return $this->isSlong( $m[0] ) && $this->isSlong( $m[1] );
830 $this->debug( $in, __FUNCTION__,
'fed a non-fraction value' );
844 private function validate( $section, $tag, $val, $recursive =
false ): bool {
845 $debug =
"tag is '$tag'";
846 $etype = $this->mExifTags[$section][$tag];
848 if ( is_array( $etype ) ) {
849 [ $etype, $ecount ] = $etype;
857 if ( is_array( $val ) ) {
858 $count = count( $val );
859 if ( $ecount !== $count ) {
860 $this->debug( $val, __FUNCTION__,
"Expected $ecount elements for $tag but got $count" );
866 foreach ( $val as $v ) {
867 if ( !$this->validate( $section, $tag, $v,
true ) ) {
876 if ( $val ===
null ) {
881 switch ( (
string)$etype ) {
882 case (
string)self::BYTE:
883 $this->debug( $val, __FUNCTION__, $debug );
885 return $this->isByte( $val );
886 case (
string)self::ASCII:
887 $this->debug( $val, __FUNCTION__, $debug );
889 return $this->isASCII( $val );
890 case (
string)self::SHORT:
891 $this->debug( $val, __FUNCTION__, $debug );
893 return $this->isShort( $val );
894 case (
string)self::LONG:
895 $this->debug( $val, __FUNCTION__, $debug );
897 return $this->isLong( $val );
898 case (
string)self::RATIONAL:
899 $this->debug( $val, __FUNCTION__, $debug );
901 return $this->isRational( $val );
902 case (
string)self::SHORT_OR_LONG:
903 $this->debug( $val, __FUNCTION__, $debug );
905 return $this->isShort( $val ) || $this->isLong( $val );
906 case (
string)self::UNDEFINED:
907 $this->debug( $val, __FUNCTION__, $debug );
909 return $this->isUndefined( $val );
910 case (
string)self::SLONG:
911 $this->debug( $val, __FUNCTION__, $debug );
913 return $this->isSlong( $val );
914 case (
string)self::SRATIONAL:
915 $this->debug( $val, __FUNCTION__, $debug );
917 return $this->isSrational( $val );
918 case (
string)self::IGNORE:
919 $this->debug( $val, __FUNCTION__, $debug );
923 $this->debug( $val, __FUNCTION__,
"The tag '$tag' is unknown" );
936 private function debug( $in, $fname, $action =
null ) {
940 $type = get_debug_type( $in );
941 $class = ucfirst( __CLASS__ );
942 if ( is_array( $in ) ) {
943 $in = print_r( $in,
true );
946 if ( $action ===
true ) {
947 wfDebugLog( $this->log,
"$class::$fname: accepted: '$in' (type: $type)" );
948 } elseif ( $action ===
false ) {
949 wfDebugLog( $this->log,
"$class::$fname: rejected: '$in' (type: $type)" );
950 } elseif ( $action ===
null ) {
951 wfDebugLog( $this->log,
"$class::$fname: input was: '$in' (type: $type)" );
953 wfDebugLog( $this->log,
"$class::$fname: $action (type: $type; content: '$in')" );
963 private function debugFile( $fname, $io ) {
967 $class = ucfirst( __CLASS__ );
969 wfDebugLog( $this->log,
"$class::$fname: begin processing: '{$this->basename}'" );
971 wfDebugLog( $this->log,
"$class::$fname: end processing: '{$this->basename}'" );