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