]> scripts.mit.edu Git - autoinstalls/mediawiki.git/blob - includes/media/FormatMetadata.php
MediaWiki 1.30.2
[autoinstalls/mediawiki.git] / includes / media / FormatMetadata.php
1 <?php
2 /**
3  * Formatting of image metadata values into human readable form.
4  *
5  * This program is free software; you can redistribute it and/or modify
6  * it under the terms of the GNU General Public License as published by
7  * the Free Software Foundation; either version 2 of the License, or
8  * (at your option) any later version.
9  *
10  * This program is distributed in the hope that it will be useful,
11  * but WITHOUT ANY WARRANTY; without even the implied warranty of
12  * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13  * GNU General Public License for more details.
14  *
15  * You should have received a copy of the GNU General Public License along
16  * with this program; if not, write to the Free Software Foundation, Inc.,
17  * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
18  * http://www.gnu.org/copyleft/gpl.html
19  *
20  * @ingroup Media
21  * @author Ævar Arnfjörð Bjarmason <avarab@gmail.com>
22  * @copyright Copyright © 2005, Ævar Arnfjörð Bjarmason, 2009 Brent Garber, 2010 Brian Wolff
23  * @license http://www.gnu.org/copyleft/gpl.html GNU General Public License
24  * @see http://exif.org/Exif2-2.PDF The Exif 2.2 specification
25  * @file
26  */
27 use MediaWiki\MediaWikiServices;
28 use Wikimedia\Timestamp\TimestampException;
29
30 /**
31  * Format Image metadata values into a human readable form.
32  *
33  * Note lots of these messages use the prefix 'exif' even though
34  * they may not be exif properties. For example 'exif-ImageDescription'
35  * can be the Exif ImageDescription, or it could be the iptc-iim caption
36  * property, or it could be the xmp dc:description property. This
37  * is because these messages should be independent of how the data is
38  * stored, sine the user doesn't care if the description is stored in xmp,
39  * exif, etc only that its a description. (Additionally many of these properties
40  * are merged together following the MWG standard, such that for example,
41  * exif properties override XMP properties that mean the same thing if
42  * there is a conflict).
43  *
44  * It should perhaps use a prefix like 'metadata' instead, but there
45  * is already a large number of messages using the 'exif' prefix.
46  *
47  * @ingroup Media
48  * @since 1.23 the class extends ContextSource and various formerly-public
49  *   internal methods are private
50  */
51 class FormatMetadata extends ContextSource {
52         /**
53          * Only output a single language for multi-language fields
54          * @var bool
55          * @since 1.23
56          */
57         protected $singleLang = false;
58
59         /**
60          * Trigger only outputting single language for multilanguage fields
61          *
62          * @param bool $val
63          * @since 1.23
64          */
65         public function setSingleLanguage( $val ) {
66                 $this->singleLang = $val;
67         }
68
69         /**
70          * Numbers given by Exif user agents are often magical, that is they
71          * should be replaced by a detailed explanation depending on their
72          * value which most of the time are plain integers. This function
73          * formats Exif (and other metadata) values into human readable form.
74          *
75          * This is the usual entry point for this class.
76          *
77          * @param array $tags The Exif data to format ( as returned by
78          *   Exif::getFilteredData() or BitmapMetadataHandler )
79          * @param bool|IContextSource $context Context to use (optional)
80          * @return array
81          */
82         public static function getFormattedData( $tags, $context = false ) {
83                 $obj = new FormatMetadata;
84                 if ( $context ) {
85                         $obj->setContext( $context );
86                 }
87
88                 return $obj->makeFormattedData( $tags );
89         }
90
91         /**
92          * Numbers given by Exif user agents are often magical, that is they
93          * should be replaced by a detailed explanation depending on their
94          * value which most of the time are plain integers. This function
95          * formats Exif (and other metadata) values into human readable form.
96          *
97          * @param array $tags The Exif data to format ( as returned by
98          *   Exif::getFilteredData() or BitmapMetadataHandler )
99          * @return array
100          * @since 1.23
101          */
102         public function makeFormattedData( $tags ) {
103                 $resolutionunit = !isset( $tags['ResolutionUnit'] ) || $tags['ResolutionUnit'] == 2 ? 2 : 3;
104                 unset( $tags['ResolutionUnit'] );
105
106                 foreach ( $tags as $tag => &$vals ) {
107                         // This seems ugly to wrap non-array's in an array just to unwrap again,
108                         // especially when most of the time it is not an array
109                         if ( !is_array( $tags[$tag] ) ) {
110                                 $vals = [ $vals ];
111                         }
112
113                         // _type is a special value to say what array type
114                         if ( isset( $tags[$tag]['_type'] ) ) {
115                                 $type = $tags[$tag]['_type'];
116                                 unset( $vals['_type'] );
117                         } else {
118                                 $type = 'ul'; // default unordered list.
119                         }
120
121                         // This is done differently as the tag is an array.
122                         if ( $tag == 'GPSTimeStamp' && count( $vals ) === 3 ) {
123                                 // hour min sec array
124
125                                 $h = explode( '/', $vals[0] );
126                                 $m = explode( '/', $vals[1] );
127                                 $s = explode( '/', $vals[2] );
128
129                                 // this should already be validated
130                                 // when loaded from file, but it could
131                                 // come from a foreign repo, so be
132                                 // paranoid.
133                                 if ( !isset( $h[1] )
134                                         || !isset( $m[1] )
135                                         || !isset( $s[1] )
136                                         || $h[1] == 0
137                                         || $m[1] == 0
138                                         || $s[1] == 0
139                                 ) {
140                                         continue;
141                                 }
142                                 $tags[$tag] = str_pad( intval( $h[0] / $h[1] ), 2, '0', STR_PAD_LEFT )
143                                         . ':' . str_pad( intval( $m[0] / $m[1] ), 2, '0', STR_PAD_LEFT )
144                                         . ':' . str_pad( intval( $s[0] / $s[1] ), 2, '0', STR_PAD_LEFT );
145
146                                 try {
147                                         $time = wfTimestamp( TS_MW, '1971:01:01 ' . $tags[$tag] );
148                                         // the 1971:01:01 is just a placeholder, and not shown to user.
149                                         if ( $time && intval( $time ) > 0 ) {
150                                                 $tags[$tag] = $this->getLanguage()->time( $time );
151                                         }
152                                 } catch ( TimestampException $e ) {
153                                         // This shouldn't happen, but we've seen bad formats
154                                         // such as 4-digit seconds in the wild.
155                                         // leave $tags[$tag] as-is
156                                 }
157                                 continue;
158                         }
159
160                         // The contact info is a multi-valued field
161                         // instead of the other props which are single
162                         // valued (mostly) so handle as a special case.
163                         if ( $tag === 'Contact' ) {
164                                 $vals = $this->collapseContactInfo( $vals );
165                                 continue;
166                         }
167
168                         foreach ( $vals as &$val ) {
169                                 switch ( $tag ) {
170                                         case 'Compression':
171                                                 switch ( $val ) {
172                                                         case 1:
173                                                         case 2:
174                                                         case 3:
175                                                         case 4:
176                                                         case 5:
177                                                         case 6:
178                                                         case 7:
179                                                         case 8:
180                                                         case 32773:
181                                                         case 32946:
182                                                         case 34712:
183                                                                 $val = $this->exifMsg( $tag, $val );
184                                                                 break;
185                                                         default:
186                                                                 /* If not recognized, display as is. */
187                                                                 break;
188                                                 }
189                                                 break;
190
191                                         case 'PhotometricInterpretation':
192                                                 switch ( $val ) {
193                                                         case 0:
194                                                         case 1:
195                                                         case 2:
196                                                         case 3:
197                                                         case 4:
198                                                         case 5:
199                                                         case 6:
200                                                         case 8:
201                                                         case 9:
202                                                         case 10:
203                                                         case 32803:
204                                                         case 34892:
205                                                                 $val = $this->exifMsg( $tag, $val );
206                                                                 break;
207                                                         default:
208                                                                 /* If not recognized, display as is. */
209                                                                 break;
210                                                 }
211                                                 break;
212
213                                         case 'Orientation':
214                                                 switch ( $val ) {
215                                                         case 1:
216                                                         case 2:
217                                                         case 3:
218                                                         case 4:
219                                                         case 5:
220                                                         case 6:
221                                                         case 7:
222                                                         case 8:
223                                                                 $val = $this->exifMsg( $tag, $val );
224                                                                 break;
225                                                         default:
226                                                                 /* If not recognized, display as is. */
227                                                                 break;
228                                                 }
229                                                 break;
230
231                                         case 'PlanarConfiguration':
232                                                 switch ( $val ) {
233                                                         case 1:
234                                                         case 2:
235                                                                 $val = $this->exifMsg( $tag, $val );
236                                                                 break;
237                                                         default:
238                                                                 /* If not recognized, display as is. */
239                                                                 break;
240                                                 }
241                                                 break;
242
243                                         // TODO: YCbCrSubSampling
244                                         case 'YCbCrPositioning':
245                                                 switch ( $val ) {
246                                                         case 1:
247                                                         case 2:
248                                                                 $val = $this->exifMsg( $tag, $val );
249                                                                 break;
250                                                         default:
251                                                                 /* If not recognized, display as is. */
252                                                                 break;
253                                                 }
254                                                 break;
255
256                                         case 'XResolution':
257                                         case 'YResolution':
258                                                 switch ( $resolutionunit ) {
259                                                         case 2:
260                                                                 $val = $this->exifMsg( 'XYResolution', 'i', $this->formatNum( $val ) );
261                                                                 break;
262                                                         case 3:
263                                                                 $val = $this->exifMsg( 'XYResolution', 'c', $this->formatNum( $val ) );
264                                                                 break;
265                                                         default:
266                                                                 /* If not recognized, display as is. */
267                                                                 break;
268                                                 }
269                                                 break;
270
271                                         // TODO: YCbCrCoefficients  #p27 (see annex E)
272                                         case 'ExifVersion':
273                                         case 'FlashpixVersion':
274                                                 $val = "$val" / 100;
275                                                 break;
276
277                                         case 'ColorSpace':
278                                                 switch ( $val ) {
279                                                         case 1:
280                                                         case 65535:
281                                                                 $val = $this->exifMsg( $tag, $val );
282                                                                 break;
283                                                         default:
284                                                                 /* If not recognized, display as is. */
285                                                                 break;
286                                                 }
287                                                 break;
288
289                                         case 'ComponentsConfiguration':
290                                                 switch ( $val ) {
291                                                         case 0:
292                                                         case 1:
293                                                         case 2:
294                                                         case 3:
295                                                         case 4:
296                                                         case 5:
297                                                         case 6:
298                                                                 $val = $this->exifMsg( $tag, $val );
299                                                                 break;
300                                                         default:
301                                                                 /* If not recognized, display as is. */
302                                                                 break;
303                                                 }
304                                                 break;
305
306                                         case 'DateTime':
307                                         case 'DateTimeOriginal':
308                                         case 'DateTimeDigitized':
309                                         case 'DateTimeReleased':
310                                         case 'DateTimeExpires':
311                                         case 'GPSDateStamp':
312                                         case 'dc-date':
313                                         case 'DateTimeMetadata':
314                                                 if ( $val == '0000:00:00 00:00:00' || $val == '    :  :     :  :  ' ) {
315                                                         $val = $this->msg( 'exif-unknowndate' )->text();
316                                                 } elseif ( preg_match(
317                                                         '/^(?:\d{4}):(?:\d\d):(?:\d\d) (?:\d\d):(?:\d\d):(?:\d\d)$/D',
318                                                         $val
319                                                 ) ) {
320                                                         // Full date.
321                                                         $time = wfTimestamp( TS_MW, $val );
322                                                         if ( $time && intval( $time ) > 0 ) {
323                                                                 $val = $this->getLanguage()->timeanddate( $time );
324                                                         }
325                                                 } elseif ( preg_match( '/^(?:\d{4}):(?:\d\d):(?:\d\d) (?:\d\d):(?:\d\d)$/D', $val ) ) {
326                                                         // No second field. Still format the same
327                                                         // since timeanddate doesn't include seconds anyways,
328                                                         // but second still available in api
329                                                         $time = wfTimestamp( TS_MW, $val . ':00' );
330                                                         if ( $time && intval( $time ) > 0 ) {
331                                                                 $val = $this->getLanguage()->timeanddate( $time );
332                                                         }
333                                                 } elseif ( preg_match( '/^(?:\d{4}):(?:\d\d):(?:\d\d)$/D', $val ) ) {
334                                                         // If only the date but not the time is filled in.
335                                                         $time = wfTimestamp( TS_MW, substr( $val, 0, 4 )
336                                                                 . substr( $val, 5, 2 )
337                                                                 . substr( $val, 8, 2 )
338                                                                 . '000000' );
339                                                         if ( $time && intval( $time ) > 0 ) {
340                                                                 $val = $this->getLanguage()->date( $time );
341                                                         }
342                                                 }
343                                                 // else it will just output $val without formatting it.
344                                                 break;
345
346                                         case 'ExposureProgram':
347                                                 switch ( $val ) {
348                                                         case 0:
349                                                         case 1:
350                                                         case 2:
351                                                         case 3:
352                                                         case 4:
353                                                         case 5:
354                                                         case 6:
355                                                         case 7:
356                                                         case 8:
357                                                                 $val = $this->exifMsg( $tag, $val );
358                                                                 break;
359                                                         default:
360                                                                 /* If not recognized, display as is. */
361                                                                 break;
362                                                 }
363                                                 break;
364
365                                         case 'SubjectDistance':
366                                                 $val = $this->exifMsg( $tag, '', $this->formatNum( $val ) );
367                                                 break;
368
369                                         case 'MeteringMode':
370                                                 switch ( $val ) {
371                                                         case 0:
372                                                         case 1:
373                                                         case 2:
374                                                         case 3:
375                                                         case 4:
376                                                         case 5:
377                                                         case 6:
378                                                         case 7:
379                                                         case 255:
380                                                                 $val = $this->exifMsg( $tag, $val );
381                                                                 break;
382                                                         default:
383                                                                 /* If not recognized, display as is. */
384                                                                 break;
385                                                 }
386                                                 break;
387
388                                         case 'LightSource':
389                                                 switch ( $val ) {
390                                                         case 0:
391                                                         case 1:
392                                                         case 2:
393                                                         case 3:
394                                                         case 4:
395                                                         case 9:
396                                                         case 10:
397                                                         case 11:
398                                                         case 12:
399                                                         case 13:
400                                                         case 14:
401                                                         case 15:
402                                                         case 17:
403                                                         case 18:
404                                                         case 19:
405                                                         case 20:
406                                                         case 21:
407                                                         case 22:
408                                                         case 23:
409                                                         case 24:
410                                                         case 255:
411                                                                 $val = $this->exifMsg( $tag, $val );
412                                                                 break;
413                                                         default:
414                                                                 /* If not recognized, display as is. */
415                                                                 break;
416                                                 }
417                                                 break;
418
419                                         case 'Flash':
420                                                 $flashDecode = [
421                                                         'fired' => $val & 0b00000001,
422                                                         'return' => ( $val & 0b00000110 ) >> 1,
423                                                         'mode' => ( $val & 0b00011000 ) >> 3,
424                                                         'function' => ( $val & 0b00100000 ) >> 5,
425                                                         'redeye' => ( $val & 0b01000000 ) >> 6,
426                                                         // 'reserved' => ( $val & 0b10000000 ) >> 7,
427                                                 ];
428                                                 $flashMsgs = [];
429                                                 # We do not need to handle unknown values since all are used.
430                                                 foreach ( $flashDecode as $subTag => $subValue ) {
431                                                         # We do not need any message for zeroed values.
432                                                         if ( $subTag != 'fired' && $subValue == 0 ) {
433                                                                 continue;
434                                                         }
435                                                         $fullTag = $tag . '-' . $subTag;
436                                                         $flashMsgs[] = $this->exifMsg( $fullTag, $subValue );
437                                                 }
438                                                 $val = $this->getLanguage()->commaList( $flashMsgs );
439                                                 break;
440
441                                         case 'FocalPlaneResolutionUnit':
442                                                 switch ( $val ) {
443                                                         case 2:
444                                                                 $val = $this->exifMsg( $tag, $val );
445                                                                 break;
446                                                         default:
447                                                                 /* If not recognized, display as is. */
448                                                                 break;
449                                                 }
450                                                 break;
451
452                                         case 'SensingMethod':
453                                                 switch ( $val ) {
454                                                         case 1:
455                                                         case 2:
456                                                         case 3:
457                                                         case 4:
458                                                         case 5:
459                                                         case 7:
460                                                         case 8:
461                                                                 $val = $this->exifMsg( $tag, $val );
462                                                                 break;
463                                                         default:
464                                                                 /* If not recognized, display as is. */
465                                                                 break;
466                                                 }
467                                                 break;
468
469                                         case 'FileSource':
470                                                 switch ( $val ) {
471                                                         case 3:
472                                                                 $val = $this->exifMsg( $tag, $val );
473                                                                 break;
474                                                         default:
475                                                                 /* If not recognized, display as is. */
476                                                                 break;
477                                                 }
478                                                 break;
479
480                                         case 'SceneType':
481                                                 switch ( $val ) {
482                                                         case 1:
483                                                                 $val = $this->exifMsg( $tag, $val );
484                                                                 break;
485                                                         default:
486                                                                 /* If not recognized, display as is. */
487                                                                 break;
488                                                 }
489                                                 break;
490
491                                         case 'CustomRendered':
492                                                 switch ( $val ) {
493                                                         case 0:
494                                                         case 1:
495                                                                 $val = $this->exifMsg( $tag, $val );
496                                                                 break;
497                                                         default:
498                                                                 /* If not recognized, display as is. */
499                                                                 break;
500                                                 }
501                                                 break;
502
503                                         case 'ExposureMode':
504                                                 switch ( $val ) {
505                                                         case 0:
506                                                         case 1:
507                                                         case 2:
508                                                                 $val = $this->exifMsg( $tag, $val );
509                                                                 break;
510                                                         default:
511                                                                 /* If not recognized, display as is. */
512                                                                 break;
513                                                 }
514                                                 break;
515
516                                         case 'WhiteBalance':
517                                                 switch ( $val ) {
518                                                         case 0:
519                                                         case 1:
520                                                                 $val = $this->exifMsg( $tag, $val );
521                                                                 break;
522                                                         default:
523                                                                 /* If not recognized, display as is. */
524                                                                 break;
525                                                 }
526                                                 break;
527
528                                         case 'SceneCaptureType':
529                                                 switch ( $val ) {
530                                                         case 0:
531                                                         case 1:
532                                                         case 2:
533                                                         case 3:
534                                                                 $val = $this->exifMsg( $tag, $val );
535                                                                 break;
536                                                         default:
537                                                                 /* If not recognized, display as is. */
538                                                                 break;
539                                                 }
540                                                 break;
541
542                                         case 'GainControl':
543                                                 switch ( $val ) {
544                                                         case 0:
545                                                         case 1:
546                                                         case 2:
547                                                         case 3:
548                                                         case 4:
549                                                                 $val = $this->exifMsg( $tag, $val );
550                                                                 break;
551                                                         default:
552                                                                 /* If not recognized, display as is. */
553                                                                 break;
554                                                 }
555                                                 break;
556
557                                         case 'Contrast':
558                                                 switch ( $val ) {
559                                                         case 0:
560                                                         case 1:
561                                                         case 2:
562                                                                 $val = $this->exifMsg( $tag, $val );
563                                                                 break;
564                                                         default:
565                                                                 /* If not recognized, display as is. */
566                                                                 break;
567                                                 }
568                                                 break;
569
570                                         case 'Saturation':
571                                                 switch ( $val ) {
572                                                         case 0:
573                                                         case 1:
574                                                         case 2:
575                                                                 $val = $this->exifMsg( $tag, $val );
576                                                                 break;
577                                                         default:
578                                                                 /* If not recognized, display as is. */
579                                                                 break;
580                                                 }
581                                                 break;
582
583                                         case 'Sharpness':
584                                                 switch ( $val ) {
585                                                         case 0:
586                                                         case 1:
587                                                         case 2:
588                                                                 $val = $this->exifMsg( $tag, $val );
589                                                                 break;
590                                                         default:
591                                                                 /* If not recognized, display as is. */
592                                                                 break;
593                                                 }
594                                                 break;
595
596                                         case 'SubjectDistanceRange':
597                                                 switch ( $val ) {
598                                                         case 0:
599                                                         case 1:
600                                                         case 2:
601                                                         case 3:
602                                                                 $val = $this->exifMsg( $tag, $val );
603                                                                 break;
604                                                         default:
605                                                                 /* If not recognized, display as is. */
606                                                                 break;
607                                                 }
608                                                 break;
609
610                                         // The GPS...Ref values are kept for compatibility, probably won't be reached.
611                                         case 'GPSLatitudeRef':
612                                         case 'GPSDestLatitudeRef':
613                                                 switch ( $val ) {
614                                                         case 'N':
615                                                         case 'S':
616                                                                 $val = $this->exifMsg( 'GPSLatitude', $val );
617                                                                 break;
618                                                         default:
619                                                                 /* If not recognized, display as is. */
620                                                                 break;
621                                                 }
622                                                 break;
623
624                                         case 'GPSLongitudeRef':
625                                         case 'GPSDestLongitudeRef':
626                                                 switch ( $val ) {
627                                                         case 'E':
628                                                         case 'W':
629                                                                 $val = $this->exifMsg( 'GPSLongitude', $val );
630                                                                 break;
631                                                         default:
632                                                                 /* If not recognized, display as is. */
633                                                                 break;
634                                                 }
635                                                 break;
636
637                                         case 'GPSAltitude':
638                                                 if ( $val < 0 ) {
639                                                         $val = $this->exifMsg( 'GPSAltitude', 'below-sealevel', $this->formatNum( -$val, 3 ) );
640                                                 } else {
641                                                         $val = $this->exifMsg( 'GPSAltitude', 'above-sealevel', $this->formatNum( $val, 3 ) );
642                                                 }
643                                                 break;
644
645                                         case 'GPSStatus':
646                                                 switch ( $val ) {
647                                                         case 'A':
648                                                         case 'V':
649                                                                 $val = $this->exifMsg( $tag, $val );
650                                                                 break;
651                                                         default:
652                                                                 /* If not recognized, display as is. */
653                                                                 break;
654                                                 }
655                                                 break;
656
657                                         case 'GPSMeasureMode':
658                                                 switch ( $val ) {
659                                                         case 2:
660                                                         case 3:
661                                                                 $val = $this->exifMsg( $tag, $val );
662                                                                 break;
663                                                         default:
664                                                                 /* If not recognized, display as is. */
665                                                                 break;
666                                                 }
667                                                 break;
668
669                                         case 'GPSTrackRef':
670                                         case 'GPSImgDirectionRef':
671                                         case 'GPSDestBearingRef':
672                                                 switch ( $val ) {
673                                                         case 'T':
674                                                         case 'M':
675                                                                 $val = $this->exifMsg( 'GPSDirection', $val );
676                                                                 break;
677                                                         default:
678                                                                 /* If not recognized, display as is. */
679                                                                 break;
680                                                 }
681                                                 break;
682
683                                         case 'GPSLatitude':
684                                         case 'GPSDestLatitude':
685                                                 $val = $this->formatCoords( $val, 'latitude' );
686                                                 break;
687                                         case 'GPSLongitude':
688                                         case 'GPSDestLongitude':
689                                                 $val = $this->formatCoords( $val, 'longitude' );
690                                                 break;
691
692                                         case 'GPSSpeedRef':
693                                                 switch ( $val ) {
694                                                         case 'K':
695                                                         case 'M':
696                                                         case 'N':
697                                                                 $val = $this->exifMsg( 'GPSSpeed', $val );
698                                                                 break;
699                                                         default:
700                                                                 /* If not recognized, display as is. */
701                                                                 break;
702                                                 }
703                                                 break;
704
705                                         case 'GPSDestDistanceRef':
706                                                 switch ( $val ) {
707                                                         case 'K':
708                                                         case 'M':
709                                                         case 'N':
710                                                                 $val = $this->exifMsg( 'GPSDestDistance', $val );
711                                                                 break;
712                                                         default:
713                                                                 /* If not recognized, display as is. */
714                                                                 break;
715                                                 }
716                                                 break;
717
718                                         case 'GPSDOP':
719                                                 // See https://en.wikipedia.org/wiki/Dilution_of_precision_(GPS)
720                                                 if ( $val <= 2 ) {
721                                                         $val = $this->exifMsg( $tag, 'excellent', $this->formatNum( $val ) );
722                                                 } elseif ( $val <= 5 ) {
723                                                         $val = $this->exifMsg( $tag, 'good', $this->formatNum( $val ) );
724                                                 } elseif ( $val <= 10 ) {
725                                                         $val = $this->exifMsg( $tag, 'moderate', $this->formatNum( $val ) );
726                                                 } elseif ( $val <= 20 ) {
727                                                         $val = $this->exifMsg( $tag, 'fair', $this->formatNum( $val ) );
728                                                 } else {
729                                                         $val = $this->exifMsg( $tag, 'poor', $this->formatNum( $val ) );
730                                                 }
731                                                 break;
732
733                                         // This is not in the Exif standard, just a special
734                                         // case for our purposes which enables wikis to wikify
735                                         // the make, model and software name to link to their articles.
736                                         case 'Make':
737                                         case 'Model':
738                                                 $val = $this->exifMsg( $tag, '', $val );
739                                                 break;
740
741                                         case 'Software':
742                                                 if ( is_array( $val ) ) {
743                                                         // if its a software, version array.
744                                                         $val = $this->msg( 'exif-software-version-value', $val[0], $val[1] )->text();
745                                                 } else {
746                                                         $val = $this->exifMsg( $tag, '', $val );
747                                                 }
748                                                 break;
749
750                                         case 'ExposureTime':
751                                                 // Show the pretty fraction as well as decimal version
752                                                 $val = $this->msg( 'exif-exposuretime-format',
753                                                         $this->formatFraction( $val ), $this->formatNum( $val ) )->text();
754                                                 break;
755                                         case 'ISOSpeedRatings':
756                                                 // If its = 65535 that means its at the
757                                                 // limit of the size of Exif::short and
758                                                 // is really higher.
759                                                 if ( $val == '65535' ) {
760                                                         $val = $this->exifMsg( $tag, 'overflow' );
761                                                 } else {
762                                                         $val = $this->formatNum( $val );
763                                                 }
764                                                 break;
765                                         case 'FNumber':
766                                                 $val = $this->msg( 'exif-fnumber-format',
767                                                         $this->formatNum( $val ) )->text();
768                                                 break;
769
770                                         case 'FocalLength':
771                                         case 'FocalLengthIn35mmFilm':
772                                                 $val = $this->msg( 'exif-focallength-format',
773                                                         $this->formatNum( $val ) )->text();
774                                                 break;
775
776                                         case 'MaxApertureValue':
777                                                 if ( strpos( $val, '/' ) !== false ) {
778                                                         // need to expand this earlier to calculate fNumber
779                                                         list( $n, $d ) = explode( '/', $val );
780                                                         if ( is_numeric( $n ) && is_numeric( $d ) ) {
781                                                                 $val = $n / $d;
782                                                         }
783                                                 }
784                                                 if ( is_numeric( $val ) ) {
785                                                         $fNumber = pow( 2, $val / 2 );
786                                                         if ( $fNumber !== false ) {
787                                                                 $val = $this->msg( 'exif-maxaperturevalue-value',
788                                                                         $this->formatNum( $val ),
789                                                                         $this->formatNum( $fNumber, 2 )
790                                                                 )->text();
791                                                         }
792                                                 }
793                                                 break;
794
795                                         case 'iimCategory':
796                                                 switch ( strtolower( $val ) ) {
797                                                         // See pg 29 of IPTC photo
798                                                         // metadata standard.
799                                                         case 'ace':
800                                                         case 'clj':
801                                                         case 'dis':
802                                                         case 'fin':
803                                                         case 'edu':
804                                                         case 'evn':
805                                                         case 'hth':
806                                                         case 'hum':
807                                                         case 'lab':
808                                                         case 'lif':
809                                                         case 'pol':
810                                                         case 'rel':
811                                                         case 'sci':
812                                                         case 'soi':
813                                                         case 'spo':
814                                                         case 'war':
815                                                         case 'wea':
816                                                                 $val = $this->exifMsg(
817                                                                         'iimcategory',
818                                                                         $val
819                                                                 );
820                                                 }
821                                                 break;
822                                         case 'SubjectNewsCode':
823                                                 // Essentially like iimCategory.
824                                                 // 8 (numeric) digit hierarchical
825                                                 // classification. We decode the
826                                                 // first 2 digits, which provide
827                                                 // a broad category.
828                                                 $val = $this->convertNewsCode( $val );
829                                                 break;
830                                         case 'Urgency':
831                                                 // 1-8 with 1 being highest, 5 normal
832                                                 // 0 is reserved, and 9 is 'user-defined'.
833                                                 $urgency = '';
834                                                 if ( $val == 0 || $val == 9 ) {
835                                                         $urgency = 'other';
836                                                 } elseif ( $val < 5 && $val > 1 ) {
837                                                         $urgency = 'high';
838                                                 } elseif ( $val == 5 ) {
839                                                         $urgency = 'normal';
840                                                 } elseif ( $val <= 8 && $val > 5 ) {
841                                                         $urgency = 'low';
842                                                 }
843
844                                                 if ( $urgency !== '' ) {
845                                                         $val = $this->exifMsg( 'urgency',
846                                                                 $urgency, $val
847                                                         );
848                                                 }
849                                                 break;
850
851                                         // Things that have a unit of pixels.
852                                         case 'OriginalImageHeight':
853                                         case 'OriginalImageWidth':
854                                         case 'PixelXDimension':
855                                         case 'PixelYDimension':
856                                         case 'ImageWidth':
857                                         case 'ImageLength':
858                                                 $val = $this->formatNum( $val ) . ' ' . $this->msg( 'unit-pixel' )->text();
859                                                 break;
860
861                                         // Do not transform fields with pure text.
862                                         // For some languages the formatNum()
863                                         // conversion results to wrong output like
864                                         // foo,bar@example,com or foo٫bar@example٫com.
865                                         // Also some 'numeric' things like Scene codes
866                                         // are included here as we really don't want
867                                         // commas inserted.
868                                         case 'ImageDescription':
869                                         case 'UserComment':
870                                         case 'Artist':
871                                         case 'Copyright':
872                                         case 'RelatedSoundFile':
873                                         case 'ImageUniqueID':
874                                         case 'SpectralSensitivity':
875                                         case 'GPSSatellites':
876                                         case 'GPSVersionID':
877                                         case 'GPSMapDatum':
878                                         case 'Keywords':
879                                         case 'WorldRegionDest':
880                                         case 'CountryDest':
881                                         case 'CountryCodeDest':
882                                         case 'ProvinceOrStateDest':
883                                         case 'CityDest':
884                                         case 'SublocationDest':
885                                         case 'WorldRegionCreated':
886                                         case 'CountryCreated':
887                                         case 'CountryCodeCreated':
888                                         case 'ProvinceOrStateCreated':
889                                         case 'CityCreated':
890                                         case 'SublocationCreated':
891                                         case 'ObjectName':
892                                         case 'SpecialInstructions':
893                                         case 'Headline':
894                                         case 'Credit':
895                                         case 'Source':
896                                         case 'EditStatus':
897                                         case 'FixtureIdentifier':
898                                         case 'LocationDest':
899                                         case 'LocationDestCode':
900                                         case 'Writer':
901                                         case 'JPEGFileComment':
902                                         case 'iimSupplementalCategory':
903                                         case 'OriginalTransmissionRef':
904                                         case 'Identifier':
905                                         case 'dc-contributor':
906                                         case 'dc-coverage':
907                                         case 'dc-publisher':
908                                         case 'dc-relation':
909                                         case 'dc-rights':
910                                         case 'dc-source':
911                                         case 'dc-type':
912                                         case 'Lens':
913                                         case 'SerialNumber':
914                                         case 'CameraOwnerName':
915                                         case 'Label':
916                                         case 'Nickname':
917                                         case 'RightsCertificate':
918                                         case 'CopyrightOwner':
919                                         case 'UsageTerms':
920                                         case 'WebStatement':
921                                         case 'OriginalDocumentID':
922                                         case 'LicenseUrl':
923                                         case 'MorePermissionsUrl':
924                                         case 'AttributionUrl':
925                                         case 'PreferredAttributionName':
926                                         case 'PNGFileComment':
927                                         case 'Disclaimer':
928                                         case 'ContentWarning':
929                                         case 'GIFFileComment':
930                                         case 'SceneCode':
931                                         case 'IntellectualGenre':
932                                         case 'Event':
933                                         case 'OrginisationInImage':
934                                         case 'PersonInImage':
935
936                                                 $val = htmlspecialchars( $val );
937                                                 break;
938
939                                         case 'ObjectCycle':
940                                                 switch ( $val ) {
941                                                         case 'a':
942                                                         case 'p':
943                                                         case 'b':
944                                                                 $val = $this->exifMsg( $tag, $val );
945                                                                 break;
946                                                         default:
947                                                                 $val = htmlspecialchars( $val );
948                                                                 break;
949                                                 }
950                                                 break;
951                                         case 'Copyrighted':
952                                                 switch ( $val ) {
953                                                         case 'True':
954                                                         case 'False':
955                                                                 $val = $this->exifMsg( $tag, $val );
956                                                                 break;
957                                                 }
958                                                 break;
959                                         case 'Rating':
960                                                 if ( $val == '-1' ) {
961                                                         $val = $this->exifMsg( $tag, 'rejected' );
962                                                 } else {
963                                                         $val = $this->formatNum( $val );
964                                                 }
965                                                 break;
966
967                                         case 'LanguageCode':
968                                                 $lang = Language::fetchLanguageName( strtolower( $val ), $this->getLanguage()->getCode() );
969                                                 if ( $lang ) {
970                                                         $val = htmlspecialchars( $lang );
971                                                 } else {
972                                                         $val = htmlspecialchars( $val );
973                                                 }
974                                                 break;
975
976                                         default:
977                                                 $val = $this->formatNum( $val );
978                                                 break;
979                                 }
980                         }
981                         // End formatting values, start flattening arrays.
982                         $vals = $this->flattenArrayReal( $vals, $type );
983                 }
984
985                 return $tags;
986         }
987
988         /**
989          * Flatten an array, using the content language for any messages.
990          *
991          * @param array $vals Array of values
992          * @param string $type Type of array (either lang, ul, ol).
993          *   lang = language assoc array with keys being the lang code
994          *   ul = unordered list, ol = ordered list
995          *   type can also come from the '_type' member of $vals.
996          * @param bool $noHtml If to avoid returning anything resembling HTML.
997          *   (Ugly hack for backwards compatibility with old MediaWiki).
998          * @param bool|IContextSource $context
999          * @return string Single value (in wiki-syntax).
1000          * @since 1.23
1001          */
1002         public static function flattenArrayContentLang( $vals, $type = 'ul',
1003                 $noHtml = false, $context = false
1004         ) {
1005                 global $wgContLang;
1006                 $obj = new FormatMetadata;
1007                 if ( $context ) {
1008                         $obj->setContext( $context );
1009                 }
1010                 $context = new DerivativeContext( $obj->getContext() );
1011                 $context->setLanguage( $wgContLang );
1012                 $obj->setContext( $context );
1013
1014                 return $obj->flattenArrayReal( $vals, $type, $noHtml );
1015         }
1016
1017         /**
1018          * A function to collapse multivalued tags into a single value.
1019          * This turns an array of (for example) authors into a bulleted list.
1020          *
1021          * This is public on the basis it might be useful outside of this class.
1022          *
1023          * @param array $vals Array of values
1024          * @param string $type Type of array (either lang, ul, ol).
1025          *     lang = language assoc array with keys being the lang code
1026          *     ul = unordered list, ol = ordered list
1027          *     type can also come from the '_type' member of $vals.
1028          * @param bool $noHtml If to avoid returning anything resembling HTML.
1029          *   (Ugly hack for backwards compatibility with old mediawiki).
1030          * @return string Single value (in wiki-syntax).
1031          * @since 1.23
1032          */
1033         public function flattenArrayReal( $vals, $type = 'ul', $noHtml = false ) {
1034                 if ( !is_array( $vals ) ) {
1035                         return $vals; // do nothing if not an array;
1036                 }
1037
1038                 if ( isset( $vals['_type'] ) ) {
1039                         $type = $vals['_type'];
1040                         unset( $vals['_type'] );
1041                 }
1042
1043                 if ( !is_array( $vals ) ) {
1044                         return $vals; // do nothing if not an array;
1045                 } elseif ( count( $vals ) === 1 && $type !== 'lang' && isset( $vals[0] ) ) {
1046                         return $vals[0];
1047                 } elseif ( count( $vals ) === 0 ) {
1048                         wfDebug( __METHOD__ . " metadata array with 0 elements!\n" );
1049
1050                         return ""; // paranoia. This should never happen
1051                 } else {
1052                         /* @todo FIXME: This should hide some of the list entries if there are
1053                          * say more than four. Especially if a field is translated into 20
1054                          * languages, we don't want to show them all by default
1055                          */
1056                         switch ( $type ) {
1057                                 case 'lang':
1058                                         // Display default, followed by ContLang,
1059                                         // followed by the rest in no particular
1060                                         // order.
1061
1062                                         // Todo: hide some items if really long list.
1063
1064                                         $content = '';
1065
1066                                         $priorityLanguages = $this->getPriorityLanguages();
1067                                         $defaultItem = false;
1068                                         $defaultLang = false;
1069
1070                                         // If default is set, save it for later,
1071                                         // as we don't know if it's equal to
1072                                         // one of the lang codes. (In xmp
1073                                         // you specify the language for a
1074                                         // default property by having both
1075                                         // a default prop, and one in the language
1076                                         // that are identical)
1077                                         if ( isset( $vals['x-default'] ) ) {
1078                                                 $defaultItem = $vals['x-default'];
1079                                                 unset( $vals['x-default'] );
1080                                         }
1081                                         foreach ( $priorityLanguages as $pLang ) {
1082                                                 if ( isset( $vals[$pLang] ) ) {
1083                                                         $isDefault = false;
1084                                                         if ( $vals[$pLang] === $defaultItem ) {
1085                                                                 $defaultItem = false;
1086                                                                 $isDefault = true;
1087                                                         }
1088                                                         $content .= $this->langItem(
1089                                                                 $vals[$pLang], $pLang,
1090                                                                 $isDefault, $noHtml );
1091
1092                                                         unset( $vals[$pLang] );
1093
1094                                                         if ( $this->singleLang ) {
1095                                                                 return Html::rawElement( 'span',
1096                                                                         [ 'lang' => $pLang ], $vals[$pLang] );
1097                                                         }
1098                                                 }
1099                                         }
1100
1101                                         // Now do the rest.
1102                                         foreach ( $vals as $lang => $item ) {
1103                                                 if ( $item === $defaultItem ) {
1104                                                         $defaultLang = $lang;
1105                                                         continue;
1106                                                 }
1107                                                 $content .= $this->langItem( $item,
1108                                                         $lang, false, $noHtml );
1109                                                 if ( $this->singleLang ) {
1110                                                         return Html::rawElement( 'span',
1111                                                                 [ 'lang' => $lang ], $item );
1112                                                 }
1113                                         }
1114                                         if ( $defaultItem !== false ) {
1115                                                 $content = $this->langItem( $defaultItem,
1116                                                                 $defaultLang, true, $noHtml ) .
1117                                                         $content;
1118                                                 if ( $this->singleLang ) {
1119                                                         return $defaultItem;
1120                                                 }
1121                                         }
1122                                         if ( $noHtml ) {
1123                                                 return $content;
1124                                         }
1125
1126                                         return '<ul class="metadata-langlist">' .
1127                                         $content .
1128                                         '</ul>';
1129                                 case 'ol':
1130                                         if ( $noHtml ) {
1131                                                 return "\n#" . implode( "\n#", $vals );
1132                                         }
1133
1134                                         return "<ol><li>" . implode( "</li>\n<li>", $vals ) . '</li></ol>';
1135                                 case 'ul':
1136                                 default:
1137                                         if ( $noHtml ) {
1138                                                 return "\n*" . implode( "\n*", $vals );
1139                                         }
1140
1141                                         return "<ul><li>" . implode( "</li>\n<li>", $vals ) . '</li></ul>';
1142                         }
1143                 }
1144         }
1145
1146         /** Helper function for creating lists of translations.
1147          *
1148          * @param string $value Value (this is not escaped)
1149          * @param string $lang Lang code of item or false
1150          * @param bool $default If it is default value.
1151          * @param bool $noHtml If to avoid html (for back-compat)
1152          * @throws MWException
1153          * @return string Language item (Note: despite how this looks, this is
1154          *   treated as wikitext, not as HTML).
1155          */
1156         private function langItem( $value, $lang, $default = false, $noHtml = false ) {
1157                 if ( $lang === false && $default === false ) {
1158                         throw new MWException( '$lang and $default cannot both '
1159                                 . 'be false.' );
1160                 }
1161
1162                 if ( $noHtml ) {
1163                         $wrappedValue = $value;
1164                 } else {
1165                         $wrappedValue = '<span class="mw-metadata-lang-value">'
1166                                 . $value . '</span>';
1167                 }
1168
1169                 if ( $lang === false ) {
1170                         $msg = $this->msg( 'metadata-langitem-default', $wrappedValue );
1171                         if ( $noHtml ) {
1172                                 return $msg->text() . "\n\n";
1173                         } /* else */
1174
1175                         return '<li class="mw-metadata-lang-default">'
1176                                 . $msg->text()
1177                                 . "</li>\n";
1178                 }
1179
1180                 $lowLang = strtolower( $lang );
1181                 $langName = Language::fetchLanguageName( $lowLang );
1182                 if ( $langName === '' ) {
1183                         // try just the base language name. (aka en-US -> en ).
1184                         list( $langPrefix ) = explode( '-', $lowLang, 2 );
1185                         $langName = Language::fetchLanguageName( $langPrefix );
1186                         if ( $langName === '' ) {
1187                                 // give up.
1188                                 $langName = $lang;
1189                         }
1190                 }
1191                 // else we have a language specified
1192
1193                 $msg = $this->msg( 'metadata-langitem', $wrappedValue, $langName, $lang );
1194                 if ( $noHtml ) {
1195                         return '*' . $msg->text();
1196                 } /* else: */
1197
1198                 $item = '<li class="mw-metadata-lang-code-'
1199                         . $lang;
1200                 if ( $default ) {
1201                         $item .= ' mw-metadata-lang-default';
1202                 }
1203                 $item .= '" lang="' . $lang . '">';
1204                 $item .= $msg->text();
1205                 $item .= "</li>\n";
1206
1207                 return $item;
1208         }
1209
1210         /**
1211          * Convenience function for getFormattedData()
1212          *
1213          * @param string $tag The tag name to pass on
1214          * @param string $val The value of the tag
1215          * @param string $arg An argument to pass ($1)
1216          * @param string $arg2 A 2nd argument to pass ($2)
1217          * @return string The text content of "exif-$tag-$val" message in lower case
1218          */
1219         private function exifMsg( $tag, $val, $arg = null, $arg2 = null ) {
1220                 global $wgContLang;
1221
1222                 if ( $val === '' ) {
1223                         $val = 'value';
1224                 }
1225
1226                 return $this->msg( $wgContLang->lc( "exif-$tag-$val" ), $arg, $arg2 )->text();
1227         }
1228
1229         /**
1230          * Format a number, convert numbers from fractions into floating point
1231          * numbers, joins arrays of numbers with commas.
1232          *
1233          * @param mixed $num The value to format
1234          * @param float|int|bool $round Digits to round to or false.
1235          * @return mixed A floating point number or whatever we were fed
1236          */
1237         private function formatNum( $num, $round = false ) {
1238                 $m = [];
1239                 if ( is_array( $num ) ) {
1240                         $out = [];
1241                         foreach ( $num as $number ) {
1242                                 $out[] = $this->formatNum( $number );
1243                         }
1244
1245                         return $this->getLanguage()->commaList( $out );
1246                 }
1247                 if ( preg_match( '/^(-?\d+)\/(\d+)$/', $num, $m ) ) {
1248                         if ( $m[2] != 0 ) {
1249                                 $newNum = $m[1] / $m[2];
1250                                 if ( $round !== false ) {
1251                                         $newNum = round( $newNum, $round );
1252                                 }
1253                         } else {
1254                                 $newNum = $num;
1255                         }
1256
1257                         return $this->getLanguage()->formatNum( $newNum );
1258                 } else {
1259                         if ( is_numeric( $num ) && $round !== false ) {
1260                                 $num = round( $num, $round );
1261                         }
1262
1263                         return $this->getLanguage()->formatNum( $num );
1264                 }
1265         }
1266
1267         /**
1268          * Format a rational number, reducing fractions
1269          *
1270          * @param mixed $num The value to format
1271          * @return mixed A floating point number or whatever we were fed
1272          */
1273         private function formatFraction( $num ) {
1274                 $m = [];
1275                 if ( preg_match( '/^(-?\d+)\/(\d+)$/', $num, $m ) ) {
1276                         $numerator = intval( $m[1] );
1277                         $denominator = intval( $m[2] );
1278                         $gcd = $this->gcd( abs( $numerator ), $denominator );
1279                         if ( $gcd != 0 ) {
1280                                 // 0 shouldn't happen! ;)
1281                                 return $this->formatNum( $numerator / $gcd ) . '/' . $this->formatNum( $denominator / $gcd );
1282                         }
1283                 }
1284
1285                 return $this->formatNum( $num );
1286         }
1287
1288         /**
1289          * Calculate the greatest common divisor of two integers.
1290          *
1291          * @param int $a Numerator
1292          * @param int $b Denominator
1293          * @return int
1294          */
1295         private function gcd( $a, $b ) {
1296                 /*
1297                         // https://en.wikipedia.org/wiki/Euclidean_algorithm
1298                         // Recursive form would be:
1299                         if( $b == 0 )
1300                                 return $a;
1301                         else
1302                                 return gcd( $b, $a % $b );
1303                 */
1304                 while ( $b != 0 ) {
1305                         $remainder = $a % $b;
1306
1307                         // tail recursion...
1308                         $a = $b;
1309                         $b = $remainder;
1310                 }
1311
1312                 return $a;
1313         }
1314
1315         /**
1316          * Fetch the human readable version of a news code.
1317          * A news code is an 8 digit code. The first two
1318          * digits are a general classification, so we just
1319          * translate that.
1320          *
1321          * Note, leading 0's are significant, so this is
1322          * a string, not an int.
1323          *
1324          * @param string $val The 8 digit news code.
1325          * @return string The human readable form
1326          */
1327         private function convertNewsCode( $val ) {
1328                 if ( !preg_match( '/^\d{8}$/D', $val ) ) {
1329                         // Not a valid news code.
1330                         return $val;
1331                 }
1332                 $cat = '';
1333                 switch ( substr( $val, 0, 2 ) ) {
1334                         case '01':
1335                                 $cat = 'ace';
1336                                 break;
1337                         case '02':
1338                                 $cat = 'clj';
1339                                 break;
1340                         case '03':
1341                                 $cat = 'dis';
1342                                 break;
1343                         case '04':
1344                                 $cat = 'fin';
1345                                 break;
1346                         case '05':
1347                                 $cat = 'edu';
1348                                 break;
1349                         case '06':
1350                                 $cat = 'evn';
1351                                 break;
1352                         case '07':
1353                                 $cat = 'hth';
1354                                 break;
1355                         case '08':
1356                                 $cat = 'hum';
1357                                 break;
1358                         case '09':
1359                                 $cat = 'lab';
1360                                 break;
1361                         case '10':
1362                                 $cat = 'lif';
1363                                 break;
1364                         case '11':
1365                                 $cat = 'pol';
1366                                 break;
1367                         case '12':
1368                                 $cat = 'rel';
1369                                 break;
1370                         case '13':
1371                                 $cat = 'sci';
1372                                 break;
1373                         case '14':
1374                                 $cat = 'soi';
1375                                 break;
1376                         case '15':
1377                                 $cat = 'spo';
1378                                 break;
1379                         case '16':
1380                                 $cat = 'war';
1381                                 break;
1382                         case '17':
1383                                 $cat = 'wea';
1384                                 break;
1385                 }
1386                 if ( $cat !== '' ) {
1387                         $catMsg = $this->exifMsg( 'iimcategory', $cat );
1388                         $val = $this->exifMsg( 'subjectnewscode', '', $val, $catMsg );
1389                 }
1390
1391                 return $val;
1392         }
1393
1394         /**
1395          * Format a coordinate value, convert numbers from floating point
1396          * into degree minute second representation.
1397          *
1398          * @param int $coord Degrees, minutes and seconds
1399          * @param string $type Latitude or longitude (for if its a NWS or E)
1400          * @return mixed A floating point number or whatever we were fed
1401          */
1402         private function formatCoords( $coord, $type ) {
1403                 $ref = '';
1404                 if ( $coord < 0 ) {
1405                         $nCoord = -$coord;
1406                         if ( $type === 'latitude' ) {
1407                                 $ref = 'S';
1408                         } elseif ( $type === 'longitude' ) {
1409                                 $ref = 'W';
1410                         }
1411                 } else {
1412                         $nCoord = $coord;
1413                         if ( $type === 'latitude' ) {
1414                                 $ref = 'N';
1415                         } elseif ( $type === 'longitude' ) {
1416                                 $ref = 'E';
1417                         }
1418                 }
1419
1420                 $deg = floor( $nCoord );
1421                 $min = floor( ( $nCoord - $deg ) * 60.0 );
1422                 $sec = round( ( ( $nCoord - $deg ) - $min / 60 ) * 3600, 2 );
1423
1424                 $deg = $this->formatNum( $deg );
1425                 $min = $this->formatNum( $min );
1426                 $sec = $this->formatNum( $sec );
1427
1428                 return $this->msg( 'exif-coordinate-format', $deg, $min, $sec, $ref, $coord )->text();
1429         }
1430
1431         /**
1432          * Format the contact info field into a single value.
1433          *
1434          * This function might be called from
1435          * JpegHandler::convertMetadataVersion which is why it is
1436          * public.
1437          *
1438          * @param array $vals Array with fields of the ContactInfo
1439          *    struct defined in the IPTC4XMP spec. Or potentially
1440          *    an array with one element that is a free form text
1441          *    value from the older iptc iim 1:118 prop.
1442          * @return string HTML-ish looking wikitext
1443          * @since 1.23 no longer static
1444          */
1445         public function collapseContactInfo( $vals ) {
1446                 if ( !( isset( $vals['CiAdrExtadr'] )
1447                         || isset( $vals['CiAdrCity'] )
1448                         || isset( $vals['CiAdrCtry'] )
1449                         || isset( $vals['CiEmailWork'] )
1450                         || isset( $vals['CiTelWork'] )
1451                         || isset( $vals['CiAdrPcode'] )
1452                         || isset( $vals['CiAdrRegion'] )
1453                         || isset( $vals['CiUrlWork'] )
1454                 ) ) {
1455                         // We don't have any sub-properties
1456                         // This could happen if its using old
1457                         // iptc that just had this as a free-form
1458                         // text value.
1459                         // Note: We run this through htmlspecialchars
1460                         // partially to be consistent, and partially
1461                         // because people often insert >, etc into
1462                         // the metadata which should not be interpreted
1463                         // but we still want to auto-link urls.
1464                         foreach ( $vals as &$val ) {
1465                                 $val = htmlspecialchars( $val );
1466                         }
1467
1468                         return $this->flattenArrayReal( $vals );
1469                 } else {
1470                         // We have a real ContactInfo field.
1471                         // Its unclear if all these fields have to be
1472                         // set, so assume they do not.
1473                         $url = $tel = $street = $city = $country = '';
1474                         $email = $postal = $region = '';
1475
1476                         // Also note, some of the class names this uses
1477                         // are similar to those used by hCard. This is
1478                         // mostly because they're sensible names. This
1479                         // does not (and does not attempt to) output
1480                         // stuff in the hCard microformat. However it
1481                         // might output in the adr microformat.
1482
1483                         if ( isset( $vals['CiAdrExtadr'] ) ) {
1484                                 // Todo: This can potentially be multi-line.
1485                                 // Need to check how that works in XMP.
1486                                 $street = '<span class="extended-address">'
1487                                         . htmlspecialchars(
1488                                                 $vals['CiAdrExtadr'] )
1489                                         . '</span>';
1490                         }
1491                         if ( isset( $vals['CiAdrCity'] ) ) {
1492                                 $city = '<span class="locality">'
1493                                         . htmlspecialchars( $vals['CiAdrCity'] )
1494                                         . '</span>';
1495                         }
1496                         if ( isset( $vals['CiAdrCtry'] ) ) {
1497                                 $country = '<span class="country-name">'
1498                                         . htmlspecialchars( $vals['CiAdrCtry'] )
1499                                         . '</span>';
1500                         }
1501                         if ( isset( $vals['CiEmailWork'] ) ) {
1502                                 $emails = [];
1503                                 // Have to split multiple emails at commas/new lines.
1504                                 $splitEmails = explode( "\n", $vals['CiEmailWork'] );
1505                                 foreach ( $splitEmails as $e1 ) {
1506                                         // Also split on comma
1507                                         foreach ( explode( ',', $e1 ) as $e2 ) {
1508                                                 $finalEmail = trim( $e2 );
1509                                                 if ( $finalEmail == ',' || $finalEmail == '' ) {
1510                                                         continue;
1511                                                 }
1512                                                 if ( strpos( $finalEmail, '<' ) !== false ) {
1513                                                         // Don't do fancy formatting to
1514                                                         // "My name" <foo@bar.com> style stuff
1515                                                         $emails[] = $finalEmail;
1516                                                 } else {
1517                                                         $emails[] = '[mailto:'
1518                                                                 . $finalEmail
1519                                                                 . ' <span class="email">'
1520                                                                 . $finalEmail
1521                                                                 . '</span>]';
1522                                                 }
1523                                         }
1524                                 }
1525                                 $email = implode( ', ', $emails );
1526                         }
1527                         if ( isset( $vals['CiTelWork'] ) ) {
1528                                 $tel = '<span class="tel">'
1529                                         . htmlspecialchars( $vals['CiTelWork'] )
1530                                         . '</span>';
1531                         }
1532                         if ( isset( $vals['CiAdrPcode'] ) ) {
1533                                 $postal = '<span class="postal-code">'
1534                                         . htmlspecialchars(
1535                                                 $vals['CiAdrPcode'] )
1536                                         . '</span>';
1537                         }
1538                         if ( isset( $vals['CiAdrRegion'] ) ) {
1539                                 // Note this is province/state.
1540                                 $region = '<span class="region">'
1541                                         . htmlspecialchars(
1542                                                 $vals['CiAdrRegion'] )
1543                                         . '</span>';
1544                         }
1545                         if ( isset( $vals['CiUrlWork'] ) ) {
1546                                 $url = '<span class="url">'
1547                                         . htmlspecialchars( $vals['CiUrlWork'] )
1548                                         . '</span>';
1549                         }
1550
1551                         return $this->msg( 'exif-contact-value', $email, $url,
1552                                 $street, $city, $region, $postal, $country,
1553                                 $tel )->text();
1554                 }
1555         }
1556
1557         /**
1558          * Get a list of fields that are visible by default.
1559          *
1560          * @return array
1561          * @since 1.23
1562          */
1563         public static function getVisibleFields() {
1564                 $fields = [];
1565                 $lines = explode( "\n", wfMessage( 'metadata-fields' )->inContentLanguage()->text() );
1566                 foreach ( $lines as $line ) {
1567                         $matches = [];
1568                         if ( preg_match( '/^\\*\s*(.*?)\s*$/', $line, $matches ) ) {
1569                                 $fields[] = $matches[1];
1570                         }
1571                 }
1572                 $fields = array_map( 'strtolower', $fields );
1573
1574                 return $fields;
1575         }
1576
1577         /**
1578          * Get an array of extended metadata. (See the imageinfo API for format.)
1579          *
1580          * @param File $file File to use
1581          * @return array [<property name> => ['value' => <value>]], or [] on error
1582          * @since 1.23
1583          */
1584         public function fetchExtendedMetadata( File $file ) {
1585                 $cache = MediaWikiServices::getInstance()->getMainWANObjectCache();
1586
1587                 // If revision deleted, exit immediately
1588                 if ( $file->isDeleted( File::DELETED_FILE ) ) {
1589                         return [];
1590                 }
1591
1592                 $cacheKey = $cache->makeKey(
1593                         'getExtendedMetadata',
1594                         $this->getLanguage()->getCode(),
1595                         (int)$this->singleLang,
1596                         $file->getSha1()
1597                 );
1598
1599                 $cachedValue = $cache->get( $cacheKey );
1600                 if (
1601                         $cachedValue
1602                         && Hooks::run( 'ValidateExtendedMetadataCache', [ $cachedValue['timestamp'], $file ] )
1603                 ) {
1604                         $extendedMetadata = $cachedValue['data'];
1605                 } else {
1606                         $maxCacheTime = ( $file instanceof ForeignAPIFile ) ? 60 * 60 * 12 : 60 * 60 * 24 * 30;
1607                         $fileMetadata = $this->getExtendedMetadataFromFile( $file );
1608                         $extendedMetadata = $this->getExtendedMetadataFromHook( $file, $fileMetadata, $maxCacheTime );
1609                         if ( $this->singleLang ) {
1610                                 $this->resolveMultilangMetadata( $extendedMetadata );
1611                         }
1612                         $this->discardMultipleValues( $extendedMetadata );
1613                         // Make sure the metadata won't break the API when an XML format is used.
1614                         // This is an API-specific function so it would be cleaner to call it from
1615                         // outside fetchExtendedMetadata, but this way we don't need to redo the
1616                         // computation on a cache hit.
1617                         $this->sanitizeArrayForAPI( $extendedMetadata );
1618                         $valueToCache = [ 'data' => $extendedMetadata, 'timestamp' => wfTimestampNow() ];
1619                         $cache->set( $cacheKey, $valueToCache, $maxCacheTime );
1620                 }
1621
1622                 return $extendedMetadata;
1623         }
1624
1625         /**
1626          * Get file-based metadata in standardized format.
1627          *
1628          * Note that for a remote file, this might return metadata supplied by extensions.
1629          *
1630          * @param File $file File to use
1631          * @return array [<property name> => ['value' => <value>]], or [] on error
1632          * @since 1.23
1633          */
1634         protected function getExtendedMetadataFromFile( File $file ) {
1635                 // If this is a remote file accessed via an API request, we already
1636                 // have remote metadata so we just ignore any local one
1637                 if ( $file instanceof ForeignAPIFile ) {
1638                         // In case of error we pretend no metadata - this will get cached.
1639                         // Might or might not be a good idea.
1640                         return $file->getExtendedMetadata() ?: [];
1641                 }
1642
1643                 $uploadDate = wfTimestamp( TS_ISO_8601, $file->getTimestamp() );
1644
1645                 $fileMetadata = [
1646                         // This is modification time, which is close to "upload" time.
1647                         'DateTime' => [
1648                                 'value' => $uploadDate,
1649                                 'source' => 'mediawiki-metadata',
1650                         ],
1651                 ];
1652
1653                 $title = $file->getTitle();
1654                 if ( $title ) {
1655                         $text = $title->getText();
1656                         $pos = strrpos( $text, '.' );
1657
1658                         if ( $pos ) {
1659                                 $name = substr( $text, 0, $pos );
1660                         } else {
1661                                 $name = $text;
1662                         }
1663
1664                         $fileMetadata['ObjectName'] = [
1665                                 'value' => $name,
1666                                 'source' => 'mediawiki-metadata',
1667                         ];
1668                 }
1669
1670                 return $fileMetadata;
1671         }
1672
1673         /**
1674          * Get additional metadata from hooks in standardized format.
1675          *
1676          * @param File $file File to use
1677          * @param array $extendedMetadata
1678          * @param int &$maxCacheTime Hook handlers might use this parameter to override cache time
1679          *
1680          * @return array [<property name> => ['value' => <value>]], or [] on error
1681          * @since 1.23
1682          */
1683         protected function getExtendedMetadataFromHook( File $file, array $extendedMetadata,
1684                 &$maxCacheTime
1685         ) {
1686                 Hooks::run( 'GetExtendedMetadata', [
1687                         &$extendedMetadata,
1688                         $file,
1689                         $this->getContext(),
1690                         $this->singleLang,
1691                         &$maxCacheTime
1692                 ] );
1693
1694                 $visible = array_flip( self::getVisibleFields() );
1695                 foreach ( $extendedMetadata as $key => $value ) {
1696                         if ( !isset( $visible[strtolower( $key )] ) ) {
1697                                 $extendedMetadata[$key]['hidden'] = '';
1698                         }
1699                 }
1700
1701                 return $extendedMetadata;
1702         }
1703
1704         /**
1705          * Turns an XMP-style multilang array into a single value.
1706          * If the value is not a multilang array, it is returned unchanged.
1707          * See mediawiki.org/wiki/Manual:File_metadata_handling#Multi-language_array_format
1708          * @param mixed $value
1709          * @return mixed Value in best language, null if there were no languages at all
1710          * @since 1.23
1711          */
1712         protected function resolveMultilangValue( $value ) {
1713                 if (
1714                         !is_array( $value )
1715                         || !isset( $value['_type'] )
1716                         || $value['_type'] != 'lang'
1717                 ) {
1718                         return $value; // do nothing if not a multilang array
1719                 }
1720
1721                 // choose the language best matching user or site settings
1722                 $priorityLanguages = $this->getPriorityLanguages();
1723                 foreach ( $priorityLanguages as $lang ) {
1724                         if ( isset( $value[$lang] ) ) {
1725                                 return $value[$lang];
1726                         }
1727                 }
1728
1729                 // otherwise go with the default language, if set
1730                 if ( isset( $value['x-default'] ) ) {
1731                         return $value['x-default'];
1732                 }
1733
1734                 // otherwise just return any one language
1735                 unset( $value['_type'] );
1736                 if ( !empty( $value ) ) {
1737                         return reset( $value );
1738                 }
1739
1740                 // this should not happen; signal error
1741                 return null;
1742         }
1743
1744         /**
1745          * Turns an XMP-style multivalue array into a single value by dropping all but the first
1746          * value. If the value is not a multivalue array (or a multivalue array inside a multilang
1747          * array), it is returned unchanged.
1748          * See mediawiki.org/wiki/Manual:File_metadata_handling#Multi-language_array_format
1749          * @param mixed $value
1750          * @return mixed The value, or the first value if there were multiple ones
1751          * @since 1.25
1752          */
1753         protected function resolveMultivalueValue( $value ) {
1754                 if ( !is_array( $value ) ) {
1755                         return $value;
1756                 } elseif ( isset( $value['_type'] ) && $value['_type'] === 'lang' ) {
1757                         // if this is a multilang array, process fields separately
1758                         $newValue = [];
1759                         foreach ( $value as $k => $v ) {
1760                                 $newValue[$k] = $this->resolveMultivalueValue( $v );
1761                         }
1762                         return $newValue;
1763                 } else { // _type is 'ul' or 'ol' or missing in which case it defaults to 'ul'
1764                         list( $k, $v ) = each( $value );
1765                         if ( $k === '_type' ) {
1766                                 $v = current( $value );
1767                         }
1768                         return $v;
1769                 }
1770         }
1771
1772         /**
1773          * Takes an array returned by the getExtendedMetadata* functions,
1774          * and resolves multi-language values in it.
1775          * @param array &$metadata
1776          * @since 1.23
1777          */
1778         protected function resolveMultilangMetadata( &$metadata ) {
1779                 if ( !is_array( $metadata ) ) {
1780                         return;
1781                 }
1782                 foreach ( $metadata as &$field ) {
1783                         if ( isset( $field['value'] ) ) {
1784                                 $field['value'] = $this->resolveMultilangValue( $field['value'] );
1785                         }
1786                 }
1787         }
1788
1789         /**
1790          * Takes an array returned by the getExtendedMetadata* functions,
1791          * and turns all fields into single-valued ones by dropping extra values.
1792          * @param array &$metadata
1793          * @since 1.25
1794          */
1795         protected function discardMultipleValues( &$metadata ) {
1796                 if ( !is_array( $metadata ) ) {
1797                         return;
1798                 }
1799                 foreach ( $metadata as $key => &$field ) {
1800                         if ( $key === 'Software' || $key === 'Contact' ) {
1801                                 // we skip some fields which have composite values. They are not particularly interesting
1802                                 // and you can get them via the metadata / commonmetadata APIs anyway.
1803                                 continue;
1804                         }
1805                         if ( isset( $field['value'] ) ) {
1806                                 $field['value'] = $this->resolveMultivalueValue( $field['value'] );
1807                         }
1808                 }
1809         }
1810
1811         /**
1812          * Makes sure the given array is a valid API response fragment
1813          * @param array &$arr
1814          */
1815         protected function sanitizeArrayForAPI( &$arr ) {
1816                 if ( !is_array( $arr ) ) {
1817                         return;
1818                 }
1819
1820                 $counter = 1;
1821                 foreach ( $arr as $key => &$value ) {
1822                         $sanitizedKey = $this->sanitizeKeyForAPI( $key );
1823                         if ( $sanitizedKey !== $key ) {
1824                                 if ( isset( $arr[$sanitizedKey] ) ) {
1825                                         // Make the sanitized keys hopefully unique.
1826                                         // To make it definitely unique would be too much effort, given that
1827                                         // sanitizing is only needed for misformatted metadata anyway, but
1828                                         // this at least covers the case when $arr is numeric.
1829                                         $sanitizedKey .= $counter;
1830                                         ++$counter;
1831                                 }
1832                                 $arr[$sanitizedKey] = $arr[$key];
1833                                 unset( $arr[$key] );
1834                         }
1835                         if ( is_array( $value ) ) {
1836                                 $this->sanitizeArrayForAPI( $value );
1837                         }
1838                 }
1839
1840                 // Handle API metadata keys (particularly "_type")
1841                 $keys = array_filter( array_keys( $arr ), 'ApiResult::isMetadataKey' );
1842                 if ( $keys ) {
1843                         ApiResult::setPreserveKeysList( $arr, $keys );
1844                 }
1845         }
1846
1847         /**
1848          * Turns a string into a valid API identifier.
1849          * @param string $key
1850          * @return string
1851          * @since 1.23
1852          */
1853         protected function sanitizeKeyForAPI( $key ) {
1854                 // drop all characters which are not valid in an XML tag name
1855                 // a bunch of non-ASCII letters would be valid but probably won't
1856                 // be used so we take the easy way
1857                 $key = preg_replace( '/[^a-zA-z0-9_:.-]/', '', $key );
1858                 // drop characters which are invalid at the first position
1859                 $key = preg_replace( '/^[\d-.]+/', '', $key );
1860
1861                 if ( $key == '' ) {
1862                         $key = '_';
1863                 }
1864
1865                 // special case for an internal keyword
1866                 if ( $key == '_element' ) {
1867                         $key = 'element';
1868                 }
1869
1870                 return $key;
1871         }
1872
1873         /**
1874          * Returns a list of languages (first is best) to use when formatting multilang fields,
1875          * based on user and site preferences.
1876          * @return array
1877          * @since 1.23
1878          */
1879         protected function getPriorityLanguages() {
1880                 $priorityLanguages =
1881                         Language::getFallbacksIncludingSiteLanguage( $this->getLanguage()->getCode() );
1882                 $priorityLanguages = array_merge(
1883                         (array)$this->getLanguage()->getCode(),
1884                         $priorityLanguages[0],
1885                         $priorityLanguages[1]
1886                 );
1887
1888                 return $priorityLanguages;
1889         }
1890 }