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