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