Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
78.57% |
110 / 140 |
|
38.89% |
7 / 18 |
CRAP | |
0.00% |
0 / 1 |
WebPHandler | |
78.57% |
110 / 140 |
|
38.89% |
7 / 18 |
76.59 | |
0.00% |
0 / 1 |
getSizeAndMetadata | |
100.00% |
10 / 10 |
|
100.00% |
1 / 1 |
2 | |||
getMetadataType | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
isFileMetadataValid | |
0.00% |
0 / 11 |
|
0.00% |
0 / 1 |
42 | |||
extractMetadata | |
100.00% |
14 / 14 |
|
100.00% |
1 / 1 |
4 | |||
extractMetadataFromChunks | |
100.00% |
32 / 32 |
|
100.00% |
1 / 1 |
9 | |||
decodeMediaMetadata | |
100.00% |
20 / 20 |
|
100.00% |
1 / 1 |
8 | |||
extractChunk | |
66.67% |
2 / 3 |
|
0.00% |
0 / 1 |
3.33 | |||
decodeLossyChunkHeader | |
100.00% |
11 / 11 |
|
100.00% |
1 / 1 |
2 | |||
decodeLosslessChunkHeader | |
100.00% |
11 / 11 |
|
100.00% |
1 / 1 |
2 | |||
decodeExtendedChunkHeader | |
100.00% |
10 / 10 |
|
100.00% |
1 / 1 |
1 | |||
mustRender | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
canRender | |
0.00% |
0 / 3 |
|
0.00% |
0 / 1 |
6 | |||
isAnimatedImage | |
0.00% |
0 / 4 |
|
0.00% |
0 / 1 |
12 | |||
canAnimateThumbnail | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
getThumbType | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
hasGDSupport | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
6 | |||
getCommonMetaArray | |
0.00% |
0 / 2 |
|
0.00% |
0 / 1 |
2 | |||
formatMetadata | |
0.00% |
0 / 4 |
|
0.00% |
0 / 1 |
6 |
1 | <?php |
2 | /** |
3 | * Handler for Google's WebP format <https://developers.google.com/speed/webp/> |
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 | use MediaWiki\FileRepo\File\File; |
25 | use MediaWiki\Logger\LoggerFactory; |
26 | use MediaWiki\MediaWikiServices; |
27 | use Wikimedia\XMPReader\Reader as XMPReader; |
28 | |
29 | /** |
30 | * Handler for Google's WebP format <https://developers.google.com/speed/webp/> |
31 | * |
32 | * @ingroup Media |
33 | */ |
34 | class WebPHandler extends BitmapHandler { |
35 | /** |
36 | * Value to store in img_metadata if there was an error extracting metadata |
37 | */ |
38 | private const BROKEN_FILE = '0'; |
39 | /** |
40 | * Minimum chunk header size to be able to read all header types |
41 | */ |
42 | private const MINIMUM_CHUNK_HEADER_LENGTH = 18; |
43 | /** |
44 | * Max size of metadata chunk to extract |
45 | */ |
46 | private const MAX_METADATA_CHUNK_SIZE = 1024 * 1024 * 2; |
47 | /** |
48 | * Version of the metadata stored in db records |
49 | */ |
50 | private const _MW_WEBP_VERSION = 2; |
51 | |
52 | private const VP8X_ICC = 32; |
53 | private const VP8X_ALPHA = 16; |
54 | private const VP8X_EXIF = 8; |
55 | private const VP8X_XMP = 4; |
56 | private const VP8X_ANIM = 2; |
57 | |
58 | public function getSizeAndMetadata( $state, $filename ) { |
59 | $parsedWebPData = self::extractMetadata( $filename ); |
60 | if ( !$parsedWebPData ) { |
61 | return [ 'metadata' => [ '_error' => self::BROKEN_FILE ] ]; |
62 | } |
63 | |
64 | $parsedWebPData['metadata']['_MW_WEBP_VERSION'] = self::_MW_WEBP_VERSION; |
65 | $info = [ |
66 | 'width' => $parsedWebPData['width'], |
67 | 'height' => $parsedWebPData['height'], |
68 | 'metadata' => $parsedWebPData |
69 | ]; |
70 | return $info; |
71 | } |
72 | |
73 | public function getMetadataType( $image ) { |
74 | return 'parsed-webp'; |
75 | } |
76 | |
77 | public function isFileMetadataValid( $image ) { |
78 | $data = $image->getMetadataArray(); |
79 | if ( $data === [ '_error' => self::BROKEN_FILE ] ) { |
80 | // Do not repetitivly regenerate metadata on broken file. |
81 | return self::METADATA_GOOD; |
82 | } |
83 | |
84 | if ( !$data || !isset( $data['_error'] ) ) { |
85 | wfDebug( __METHOD__ . " invalid WebP metadata" ); |
86 | |
87 | return self::METADATA_BAD; |
88 | } |
89 | |
90 | if ( !isset( $data['metadata']['_MW_WEBP_VERSION'] ) |
91 | || $data['metadata']['_MW_WEBP_VERSION'] != self::_MW_WEBP_VERSION |
92 | ) { |
93 | wfDebug( __METHOD__ . " old but compatible WebP metadata" ); |
94 | |
95 | return self::METADATA_COMPATIBLE; |
96 | } |
97 | return self::METADATA_GOOD; |
98 | } |
99 | |
100 | /** |
101 | * Extracts the image size and WebP type from a file |
102 | * |
103 | * @param string $filename |
104 | * @return array|false Header data array with entries 'compression', 'width' and 'height', |
105 | * where 'compression' can be 'lossy', 'lossless', 'animated' or 'unknown'. False if |
106 | * file is not a valid WebP file. |
107 | */ |
108 | public static function extractMetadata( $filename ) { |
109 | wfDebugLog( 'WebP', __METHOD__ . ": Extracting metadata from $filename" ); |
110 | |
111 | $info = RiffExtractor::findChunksFromFile( $filename, 100 ); |
112 | if ( $info === false ) { |
113 | wfDebugLog( 'WebP', __METHOD__ . ": Not a valid RIFF file" ); |
114 | return false; |
115 | } |
116 | |
117 | if ( $info['fourCC'] !== 'WEBP' ) { |
118 | wfDebugLog( 'WebP', __METHOD__ . ': FourCC was not WEBP: ' . |
119 | bin2hex( $info['fourCC'] ) ); |
120 | return false; |
121 | } |
122 | $metadata = self::extractMetadataFromChunks( $info['chunks'], $filename ); |
123 | if ( !$metadata ) { |
124 | wfDebugLog( 'WebP', __METHOD__ . ": No VP8 chunks found" ); |
125 | return false; |
126 | } |
127 | |
128 | return $metadata; |
129 | } |
130 | |
131 | /** |
132 | * Extracts the image size and WebP type from a file based on the chunk list |
133 | * @param array[] $chunks Chunks as extracted by RiffExtractor |
134 | * @param string $filename |
135 | * @return array Header data array with entries 'compression', 'width' and 'height', where |
136 | * 'compression' can be 'lossy', 'lossless', 'animated' or 'unknown' |
137 | */ |
138 | public static function extractMetadataFromChunks( $chunks, $filename ) { |
139 | $vp8Info = []; |
140 | $exifData = null; |
141 | $xmpData = null; |
142 | |
143 | foreach ( $chunks as $chunk ) { |
144 | // Note, spec says it should be 'XMP ' but some real life files use "XMP\0" |
145 | if ( !in_array( $chunk['fourCC'], [ 'VP8 ', 'VP8L', 'VP8X', 'EXIF', 'XMP ', "XMP\0" ] ) ) { |
146 | // Not a chunk containing interesting metadata |
147 | continue; |
148 | } |
149 | |
150 | $chunkHeader = file_get_contents( $filename, false, null, |
151 | $chunk['start'], self::MINIMUM_CHUNK_HEADER_LENGTH ); |
152 | wfDebugLog( 'WebP', __METHOD__ . ": {$chunk['fourCC']}" ); |
153 | |
154 | switch ( $chunk['fourCC'] ) { |
155 | case 'VP8 ': |
156 | $vp8Info = array_merge( $vp8Info, |
157 | self::decodeLossyChunkHeader( $chunkHeader ) ); |
158 | break; |
159 | case 'VP8L': |
160 | $vp8Info = array_merge( $vp8Info, |
161 | self::decodeLosslessChunkHeader( $chunkHeader ) ); |
162 | break; |
163 | case 'VP8X': |
164 | $vp8Info = array_merge( $vp8Info, |
165 | self::decodeExtendedChunkHeader( $chunkHeader ) ); |
166 | // Continue looking for other chunks to improve the metadata |
167 | break; |
168 | case 'EXIF': |
169 | // Spec says ignore all but first one |
170 | $exifData ??= self::extractChunk( $chunk, $filename ); |
171 | break; |
172 | case 'XMP ': |
173 | case "XMP\0": |
174 | $xmpData ??= self::extractChunk( $chunk, $filename ); |
175 | break; |
176 | } |
177 | } |
178 | $vp8Info = array_merge( $vp8Info, |
179 | self::decodeMediaMetadata( $exifData, $xmpData, $filename ) ); |
180 | return $vp8Info; |
181 | } |
182 | |
183 | /** |
184 | * Decode metadata about the file (XMP & Exif). |
185 | * |
186 | * @param string|null $exifData Binary exif data from file |
187 | * @param string|null $xmpData XMP data from file |
188 | * @param string|null $filename (Used for logging only) |
189 | * @return array |
190 | */ |
191 | private static function decodeMediaMetadata( $exifData, $xmpData, $filename ) { |
192 | if ( $exifData === null && $xmpData === null ) { |
193 | // Nothing to do |
194 | return []; |
195 | } |
196 | $bitmapMetadataHandler = new BitmapMetadataHandler; |
197 | |
198 | if ( $xmpData && XMPReader::isSupported() ) { |
199 | $xmpReader = new XMPReader( LoggerFactory::getInstance( 'XMP' ), $filename ); |
200 | $xmpReader->parse( $xmpData ); |
201 | $res = $xmpReader->getResults(); |
202 | foreach ( $res as $type => $array ) { |
203 | $bitmapMetadataHandler->addMetadata( $array, $type ); |
204 | } |
205 | } |
206 | |
207 | if ( $exifData ) { |
208 | // The Exif section of a webp file is basically a tiff file without an image. |
209 | // Some files start with an Exif\0\0. This is wrong according to standard and |
210 | // will prevent us from reading file, so remove for compatibility. |
211 | if ( substr( $exifData, 0, 6 ) === "Exif\x00\x00" ) { |
212 | $exifData = substr( $exifData, 6 ); |
213 | } |
214 | $tmpFile = MediaWikiServices::getInstance()-> |
215 | getTempFSFileFactory()-> |
216 | newTempFSFile( 'webp-exif_', 'tiff' ); |
217 | |
218 | $exifDataFile = $tmpFile->getPath(); |
219 | file_put_contents( $exifDataFile, $exifData ); |
220 | $byteOrder = BitmapMetadataHandler::getTiffByteOrder( $exifDataFile ); |
221 | $bitmapMetadataHandler->getExif( $exifDataFile, $byteOrder ); |
222 | } |
223 | return [ 'media-metadata' => $bitmapMetadataHandler->getMetadataArray() ]; |
224 | } |
225 | |
226 | /** |
227 | * @param array $chunk Information about chunk |
228 | * @param string $filename |
229 | * @return null|string Contents of chunk (excluding fourcc, size and padding) |
230 | */ |
231 | private static function extractChunk( $chunk, $filename ) { |
232 | if ( $chunk['size'] > self::MAX_METADATA_CHUNK_SIZE || $chunk['size'] < 1 ) { |
233 | return null; |
234 | } |
235 | |
236 | // Skip first 8 bytes as that is the fourCC header followed by size of chunk. |
237 | return file_get_contents( $filename, false, null, $chunk['start'] + 8, $chunk['size'] ); |
238 | } |
239 | |
240 | /** |
241 | * Decodes a lossy chunk header |
242 | * @param string $header First few bytes of the header, expected to be at least 18 bytes long |
243 | * @return bool|array See WebPHandler::decodeHeader |
244 | */ |
245 | protected static function decodeLossyChunkHeader( $header ) { |
246 | // Bytes 0-3 are 'VP8 ' |
247 | // Bytes 4-7 are the VP8 stream size |
248 | // Bytes 8-10 are the frame tag |
249 | // Bytes 11-13 are 0x9D 0x01 0x2A called the sync code |
250 | $syncCode = substr( $header, 11, 3 ); |
251 | if ( $syncCode !== "\x9D\x01\x2A" ) { |
252 | wfDebugLog( 'WebP', __METHOD__ . ': Invalid sync code: ' . |
253 | bin2hex( $syncCode ) ); |
254 | return []; |
255 | } |
256 | // Bytes 14-17 are image size |
257 | $imageSize = unpack( 'v2', substr( $header, 14, 4 ) ); |
258 | // Image sizes are 14 bit, 2 MSB are scaling parameters which are ignored here |
259 | return [ |
260 | 'compression' => 'lossy', |
261 | 'width' => $imageSize[1] & 0x3FFF, |
262 | 'height' => $imageSize[2] & 0x3FFF |
263 | ]; |
264 | } |
265 | |
266 | /** |
267 | * Decodes a lossless chunk header |
268 | * @param string $header First few bytes of the header, expected to be at least 13 bytes long |
269 | * @return bool|array See WebPHandler::decodeHeader |
270 | */ |
271 | public static function decodeLosslessChunkHeader( $header ) { |
272 | // Bytes 0-3 are 'VP8L' |
273 | // Bytes 4-7 are chunk stream size |
274 | // Byte 8 is 0x2F called the signature |
275 | if ( $header[8] !== "\x2F" ) { |
276 | wfDebugLog( 'WebP', __METHOD__ . ': Invalid signature: ' . |
277 | bin2hex( $header[8] ) ); |
278 | return []; |
279 | } |
280 | // Bytes 9-12 contain the image size |
281 | // Bits 0-13 are width-1; bits 15-27 are height-1 |
282 | $imageSize = unpack( 'C4', substr( $header, 9, 4 ) ); |
283 | return [ |
284 | 'compression' => 'lossless', |
285 | 'width' => ( $imageSize[1] | ( ( $imageSize[2] & 0x3F ) << 8 ) ) + 1, |
286 | 'height' => ( ( ( $imageSize[2] & 0xC0 ) >> 6 ) | |
287 | ( $imageSize[3] << 2 ) | ( ( $imageSize[4] & 0x03 ) << 10 ) ) + 1 |
288 | ]; |
289 | } |
290 | |
291 | /** |
292 | * Decodes an extended chunk header |
293 | * @param string $header First few bytes of the header, expected to be at least 18 bytes long |
294 | * @return bool|array See WebPHandler::decodeHeader |
295 | */ |
296 | public static function decodeExtendedChunkHeader( $header ) { |
297 | // Bytes 0-3 are 'VP8X' |
298 | // Byte 4-7 are chunk length |
299 | // Byte 8-11 are a flag bytes |
300 | $flags = unpack( 'c', substr( $header, 8, 1 ) ); |
301 | |
302 | // Byte 12-17 are image size (24 bits) |
303 | $width = unpack( 'V', substr( $header, 12, 3 ) . "\x00" ); |
304 | $height = unpack( 'V', substr( $header, 15, 3 ) . "\x00" ); |
305 | |
306 | return [ |
307 | 'compression' => 'unknown', |
308 | 'animated' => ( $flags[1] & self::VP8X_ANIM ) === self::VP8X_ANIM, |
309 | 'transparency' => ( $flags[1] & self::VP8X_ALPHA ) === self::VP8X_ALPHA, |
310 | 'width' => ( $width[1] & 0xFFFFFF ) + 1, |
311 | 'height' => ( $height[1] & 0xFFFFFF ) + 1 |
312 | ]; |
313 | } |
314 | |
315 | /** |
316 | * @param File $file |
317 | * @return bool True, not all browsers support WebP |
318 | */ |
319 | public function mustRender( $file ) { |
320 | return true; |
321 | } |
322 | |
323 | /** |
324 | * @param File $file |
325 | * @return bool False if we are unable to render this image |
326 | */ |
327 | public function canRender( $file ) { |
328 | if ( $this->isAnimatedImage( $file ) ) { |
329 | return false; |
330 | } |
331 | return true; |
332 | } |
333 | |
334 | /** |
335 | * @param File $image |
336 | * @return bool |
337 | */ |
338 | public function isAnimatedImage( $image ) { |
339 | $metadata = $image->getMetadataArray(); |
340 | if ( isset( $metadata['animated'] ) && $metadata['animated'] === true ) { |
341 | return true; |
342 | } |
343 | |
344 | return false; |
345 | } |
346 | |
347 | public function canAnimateThumbnail( $file ) { |
348 | return false; |
349 | } |
350 | |
351 | /** |
352 | * Render files as PNG |
353 | * |
354 | * @param string $ext |
355 | * @param string $mime |
356 | * @param array|null $params |
357 | * @return array |
358 | */ |
359 | public function getThumbType( $ext, $mime, $params = null ) { |
360 | return [ 'png', 'image/png' ]; |
361 | } |
362 | |
363 | protected function hasGDSupport() { |
364 | return function_exists( 'gd_info' ) && ( gd_info()['WebP Support'] ?? false ); |
365 | } |
366 | |
367 | public function getCommonMetaArray( File $image ) { |
368 | $meta = $image->getMetadataArray(); |
369 | return $meta['media-metadata'] ?? []; |
370 | } |
371 | |
372 | public function formatMetadata( $image, $context = false ) { |
373 | $meta = $this->getCommonMetaArray( $image ); |
374 | if ( !$meta ) { |
375 | return false; |
376 | } |
377 | |
378 | return $this->formatMetadataHelper( $meta, $context ); |
379 | } |
380 | } |