1<?php
2/**
3 * Handler for bitmap images with exif metadata.
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 * @file
21 * @ingroup Media
22 */
23
24/**
25 * Stuff specific to JPEG and (built-in) TIFF handler.
26 * All metadata related, since both JPEG and TIFF support Exif.
27 *
28 * @stable to extend
29 * @ingroup Media
30 */
31class ExifBitmapHandler extends BitmapHandler {
32	/** Error extracting metadata */
33	public const BROKEN_FILE = '-1';
34
35	/** Outdated error extracting metadata */
36	public const OLD_BROKEN_FILE = '0';
37
38	public function convertMetadataVersion( $metadata, $version = 1 ) {
39		// basically flattens arrays.
40		$version = intval( explode( ';', $version, 2 )[0] );
41		if ( $version < 1 || $version >= 2 ) {
42			return $metadata;
43		}
44
45		if ( !is_array( $metadata ) ) {
46			$metadata = unserialize( $metadata );
47		}
48		if ( !isset( $metadata['MEDIAWIKI_EXIF_VERSION'] ) || $metadata['MEDIAWIKI_EXIF_VERSION'] != 2 ) {
49			return $metadata;
50		}
51
52		// Treat Software as a special case because in can contain
53		// an array of (SoftwareName, Version).
54		if ( isset( $metadata['Software'] )
55			&& is_array( $metadata['Software'] )
56			&& is_array( $metadata['Software'][0] )
57			&& isset( $metadata['Software'][0][0] )
58			&& isset( $metadata['Software'][0][1] )
59		) {
60			$metadata['Software'] = $metadata['Software'][0][0] . ' (Version '
61				. $metadata['Software'][0][1] . ')';
62		}
63
64		$formatter = new FormatMetadata;
65
66		// ContactInfo also has to be dealt with specially
67		if ( isset( $metadata['Contact'] ) ) {
68			$metadata['Contact'] = $formatter->collapseContactInfo(
69				is_array( $metadata['Contact'] ) ? $metadata['Contact'] : [ $metadata['Contact'] ]
70			);
71		}
72
73		foreach ( $metadata as &$val ) {
74			if ( is_array( $val ) ) {
75				// @phan-suppress-next-line SecurityCheck-DoubleEscaped Ambiguous with the true for nohtml
76				$val = $formatter->flattenArrayReal( $val, 'ul', true );
77			}
78		}
79		$metadata['MEDIAWIKI_EXIF_VERSION'] = 1;
80
81		return $metadata;
82	}
83
84	/**
85	 * @param File $image
86	 * @param string $metadata
87	 * @return bool|int
88	 */
89	public function isMetadataValid( $image, $metadata ) {
90		global $wgShowEXIF;
91		if ( !$wgShowEXIF ) {
92			# Metadata disabled and so an empty field is expected
93			return self::METADATA_GOOD;
94		}
95		if ( $metadata === self::OLD_BROKEN_FILE ) {
96			# Old special value indicating that there is no Exif data in the file.
97			# or that there was an error well extracting the metadata.
98			wfDebug( __METHOD__ . ": back-compat version" );
99
100			return self::METADATA_COMPATIBLE;
101		}
102		if ( $metadata === self::BROKEN_FILE ) {
103			return self::METADATA_GOOD;
104		}
105		Wikimedia\suppressWarnings();
106		$exif = unserialize( $metadata );
107		Wikimedia\restoreWarnings();
108		if ( !isset( $exif['MEDIAWIKI_EXIF_VERSION'] )
109			|| $exif['MEDIAWIKI_EXIF_VERSION'] != Exif::version()
110		) {
111			if ( isset( $exif['MEDIAWIKI_EXIF_VERSION'] )
112				&& $exif['MEDIAWIKI_EXIF_VERSION'] == 1
113			) {
114				// back-compatible but old
115				wfDebug( __METHOD__ . ": back-compat version" );
116
117				return self::METADATA_COMPATIBLE;
118			}
119			# Wrong (non-compatible) version
120			wfDebug( __METHOD__ . ": wrong version" );
121
122			return self::METADATA_BAD;
123		}
124
125		return self::METADATA_GOOD;
126	}
127
128	/**
129	 * @param File $image
130	 * @param IContextSource|false $context
131	 * @return array[]|false
132	 */
133	public function formatMetadata( $image, $context = false ) {
134		$meta = $this->getCommonMetaArray( $image );
135		if ( !$meta ) {
136			return false;
137		}
138
139		return $this->formatMetadataHelper( $meta, $context );
140	}
141
142	public function getCommonMetaArray( File $file ) {
143		$metadata = $file->getMetadata();
144		if ( $metadata === self::OLD_BROKEN_FILE
145			|| $metadata === self::BROKEN_FILE
146			|| $this->isMetadataValid( $file, $metadata ) === self::METADATA_BAD
147		) {
148			// So we don't try and display metadata from PagedTiffHandler
149			// for example when using InstantCommons.
150			return [];
151		}
152
153		$exif = unserialize( $metadata );
154		if ( !$exif ) {
155			return [];
156		}
157		unset( $exif['MEDIAWIKI_EXIF_VERSION'] );
158
159		return $exif;
160	}
161
162	public function getMetadataType( $image ) {
163		return 'exif';
164	}
165
166	/**
167	 * Wrapper for base classes ImageHandler::getImageSize() that checks for
168	 * rotation reported from metadata and swaps the sizes to match.
169	 *
170	 * @param File|FSFile $image
171	 * @param string $path
172	 * @return array|false
173	 */
174	public function getImageSize( $image, $path ) {
175		$gis = parent::getImageSize( $image, $path );
176
177		// Don't just call $image->getMetadata(); FSFile::getPropsFromPath() calls us with a bogus object.
178		// This may mean we read EXIF data twice on initial upload.
179		if ( $this->autoRotateEnabled() ) {
180			$meta = $this->getMetadata( $image, $path );
181			$rotation = $this->getRotationForExif( $meta );
182		} else {
183			$rotation = 0;
184		}
185
186		if ( $rotation == 90 || $rotation == 270 ) {
187			$width = $gis[0];
188			$gis[0] = $gis[1];
189			$gis[1] = $width;
190		}
191
192		return $gis;
193	}
194
195	/**
196	 * On supporting image formats, try to read out the low-level orientation
197	 * of the file and return the angle that the file needs to be rotated to
198	 * be viewed.
199	 *
200	 * This information is only useful when manipulating the original file;
201	 * the width and height we normally work with is logical, and will match
202	 * any produced output views.
203	 *
204	 * @param File $file
205	 * @return int 0, 90, 180 or 270
206	 */
207	public function getRotation( $file ) {
208		if ( !$this->autoRotateEnabled() ) {
209			return 0;
210		}
211
212		$data = $file->getMetadata();
213
214		return $this->getRotationForExif( $data );
215	}
216
217	/**
218	 * Given a chunk of serialized Exif metadata, return the orientation as
219	 * degrees of rotation.
220	 *
221	 * @param string|false $data
222	 * @return int 0, 90, 180 or 270
223	 * @todo FIXME: Orientation can include flipping as well; see if this is an issue!
224	 */
225	protected function getRotationForExif( $data ) {
226		if ( !$data ) {
227			return 0;
228		}
229		Wikimedia\suppressWarnings();
230		$data = unserialize( $data );
231		Wikimedia\restoreWarnings();
232		if ( isset( $data['Orientation'] ) ) {
233			# See http://sylvana.net/jpegcrop/exif_orientation.html
234			switch ( $data['Orientation'] ) {
235				case 8:
236					return 90;
237				case 3:
238					return 180;
239				case 6:
240					return 270;
241				default:
242					return 0;
243			}
244		}
245
246		return 0;
247	}
248}
249