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