Image metadata is usually a serialized string representing an array. Passing the string around internally and having everything unserialize it is an awkward convention. Also, many image handlers were reading the file twice: once for getMetadata() and again for getImageSize(). Often getMetadata() would actually read the width and height and then throw it away. So, in filerepo: * Add File::getMetadataItem(), which promises to allow partial loading of metadata per my proposal on T275268 in a future commit. * Add File::getMetadataArray(), which returns the unserialized array. Some file handlers were returning non-serializable strings from getMetadata(), so I gave them a legacy array form ['_error' => ...] * Changed MWFileProps to return the array form of metadata. * Deprecate the weird File::getImageSize(). It was apparently not called by anything, but was overridden by UnregisteredLocalFile. * Wrap serialize/unserialize with File::getMetadataForDb() and File::loadMetadataFromDb() in preparation for T275268. In MediaHandler: * Merged MediaHandler::getImageSize() and MediaHandler::getMetadata() into getSizeAndMetadata(). Deprecated the old methods. * Instead of isMetadataValid() we now have isFileMetadataValid(), which only gets a File object, so it can decide what data it needs to load. * Simplified getPageDimensions() by having it return false for non-paged media. It was not called in that case, but was implemented anyway. In specific handlers: * Rename DjVuHandler::getUnserializedMetadata() and extractTreesFromMetadata() for clarity. "Metadata" in these function names meant an XML string. * Updated DjVuImage::getImageSize() to provide image sizes in the new style. * In ExifBitmapHandler, getRotationForExif() now takes just the Orientation tag, rather than a serialized string. Also renamed for clarity. * In GIFMetadataExtractor, return the width, height and bits per channel instead of throwing them away. There was some conflation in decodeBPP() which I picked apart. Refer to GIF89a section 18. * In JpegMetadataExtractor, process the SOF0/SOF2 segment to extract bits per channel, width, height and components (channel count). This is essentially a port of PHP's getimagesize(), so should be bugwards compatible. * In PNGMetadataExtractor, return the width and height, which were previously assigned to unused local variables. I verified the implementation by referring to the specification. * In SvgHandler, retain the version validation from unpackMetadata(), but rename the function since it now takes an array as input. In tests: * In ExifBitmapTest, refactored some tests by using a provider. * In GIFHandlerTest and PNGHandlerTest, I removed the tests in which getMetadata() returns null, since it doesn't make sense when ported to getMetadataArray(). I added tests for empty arrays instead. * In tests, I retained serialization of input data since I figure it's useful to confirm that existing database rows will continue to be read correctly. I removed serialization of expected values, replacing them with plain data. * In tests, I replaced access to private class constants like BROKEN_FILE with string literals, since stability is essential. If the class constant changes, the test should fail. Elsewhere: * In maintenance/refreshImageMetadata.php, I removed the check for shrinking image metadata, since it's not easy to implement and is not future compatible. Image metadata is expected to shrink in future. Bug: T275268 Change-Id: I039785d5b6439d71dcc21dcb972177dba5c3a67d
293 lines
8.5 KiB
PHP
293 lines
8.5 KiB
PHP
<?php
|
|
/**
|
|
* Handler for Google's WebP format <https://developers.google.com/speed/webp/>
|
|
*
|
|
* This program is free software; you can redistribute it and/or modify
|
|
* it under the terms of the GNU General Public License as published by
|
|
* the Free Software Foundation; either version 2 of the License, or
|
|
* (at your option) any later version.
|
|
*
|
|
* This program is distributed in the hope that it will be useful,
|
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
* GNU General Public License for more details.
|
|
*
|
|
* You should have received a copy of the GNU General Public License along
|
|
* with this program; if not, write to the Free Software Foundation, Inc.,
|
|
* 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
|
|
* http://www.gnu.org/copyleft/gpl.html
|
|
*
|
|
* @file
|
|
* @ingroup Media
|
|
*/
|
|
|
|
/**
|
|
* Handler for Google's WebP format <https://developers.google.com/speed/webp/>
|
|
*
|
|
* @ingroup Media
|
|
*/
|
|
class WebPHandler extends BitmapHandler {
|
|
/**
|
|
* Value to store in img_metadata if there was an error extracting metadata
|
|
*/
|
|
private const BROKEN_FILE = '0';
|
|
/**
|
|
* Minimum chunk header size to be able to read all header types
|
|
*/
|
|
private const MINIMUM_CHUNK_HEADER_LENGTH = 18;
|
|
/**
|
|
* Version of the metadata stored in db records
|
|
*/
|
|
private const _MW_WEBP_VERSION = 1;
|
|
|
|
private const VP8X_ICC = 32;
|
|
private const VP8X_ALPHA = 16;
|
|
private const VP8X_EXIF = 8;
|
|
private const VP8X_XMP = 4;
|
|
private const VP8X_ANIM = 2;
|
|
|
|
public function getSizeAndMetadata( $state, $filename ) {
|
|
$parsedWebPData = self::extractMetadata( $filename );
|
|
if ( !$parsedWebPData ) {
|
|
return [ 'metadata' => [ '_error' => self::BROKEN_FILE ] ];
|
|
}
|
|
|
|
$parsedWebPData['metadata']['_MW_WEBP_VERSION'] = self::_MW_WEBP_VERSION;
|
|
$info = [
|
|
'width' => $parsedWebPData['width'],
|
|
'height' => $parsedWebPData['height'],
|
|
'metadata' => $parsedWebPData
|
|
];
|
|
return $info;
|
|
}
|
|
|
|
public function getMetadataType( $image ) {
|
|
return 'parsed-webp';
|
|
}
|
|
|
|
public function isFileMetadataValid( $image ) {
|
|
$data = $image->getMetadataArray();
|
|
if ( $data === [ '_error' => self::BROKEN_FILE ] ) {
|
|
// Do not repetitivly regenerate metadata on broken file.
|
|
return self::METADATA_GOOD;
|
|
}
|
|
|
|
if ( !$data || !isset( $data['_error'] ) ) {
|
|
wfDebug( __METHOD__ . " invalid WebP metadata" );
|
|
|
|
return self::METADATA_BAD;
|
|
}
|
|
|
|
if ( !isset( $data['metadata']['_MW_WEBP_VERSION'] )
|
|
|| $data['metadata']['_MW_WEBP_VERSION'] != self::_MW_WEBP_VERSION
|
|
) {
|
|
wfDebug( __METHOD__ . " old but compatible WebP metadata" );
|
|
|
|
return self::METADATA_COMPATIBLE;
|
|
}
|
|
return self::METADATA_GOOD;
|
|
}
|
|
|
|
/**
|
|
* Extracts the image size and WebP type from a file
|
|
*
|
|
* @param string $filename
|
|
* @return array|bool Header data array with entries 'compression', 'width' and 'height',
|
|
* where 'compression' can be 'lossy', 'lossless', 'animated' or 'unknown'. False if
|
|
* file is not a valid WebP file.
|
|
*/
|
|
public static function extractMetadata( $filename ) {
|
|
wfDebugLog( 'WebP', __METHOD__ . ": Extracting metadata from $filename" );
|
|
|
|
$info = RiffExtractor::findChunksFromFile( $filename, 100 );
|
|
if ( $info === false ) {
|
|
wfDebugLog( 'WebP', __METHOD__ . ": Not a valid RIFF file" );
|
|
return false;
|
|
}
|
|
|
|
if ( $info['fourCC'] != 'WEBP' ) {
|
|
wfDebugLog( 'WebP', __METHOD__ . ': FourCC was not WEBP: ' .
|
|
bin2hex( $info['fourCC'] ) );
|
|
return false;
|
|
}
|
|
|
|
$metadata = self::extractMetadataFromChunks( $info['chunks'], $filename );
|
|
if ( !$metadata ) {
|
|
wfDebugLog( 'WebP', __METHOD__ . ": No VP8 chunks found" );
|
|
return false;
|
|
}
|
|
|
|
return $metadata;
|
|
}
|
|
|
|
/**
|
|
* Extracts the image size and WebP type from a file based on the chunk list
|
|
* @param array[] $chunks Chunks as extracted by RiffExtractor
|
|
* @param string $filename
|
|
* @return array Header data array with entries 'compression', 'width' and 'height', where
|
|
* 'compression' can be 'lossy', 'lossless', 'animated' or 'unknown'
|
|
*/
|
|
public static function extractMetadataFromChunks( $chunks, $filename ) {
|
|
$vp8Info = [];
|
|
|
|
foreach ( $chunks as $chunk ) {
|
|
if ( !in_array( $chunk['fourCC'], [ 'VP8 ', 'VP8L', 'VP8X' ] ) ) {
|
|
// Not a chunk containing interesting metadata
|
|
continue;
|
|
}
|
|
|
|
$chunkHeader = file_get_contents( $filename, false, null,
|
|
$chunk['start'], self::MINIMUM_CHUNK_HEADER_LENGTH );
|
|
wfDebugLog( 'WebP', __METHOD__ . ": {$chunk['fourCC']}" );
|
|
|
|
switch ( $chunk['fourCC'] ) {
|
|
case 'VP8 ':
|
|
return array_merge( $vp8Info,
|
|
self::decodeLossyChunkHeader( $chunkHeader ) );
|
|
case 'VP8L':
|
|
return array_merge( $vp8Info,
|
|
self::decodeLosslessChunkHeader( $chunkHeader ) );
|
|
case 'VP8X':
|
|
$vp8Info = array_merge( $vp8Info,
|
|
self::decodeExtendedChunkHeader( $chunkHeader ) );
|
|
// Continue looking for other chunks to improve the metadata
|
|
break;
|
|
}
|
|
}
|
|
return $vp8Info;
|
|
}
|
|
|
|
/**
|
|
* Decodes a lossy chunk header
|
|
* @param string $header First few bytes of the header, expected to be at least 18 bytes long
|
|
* @return bool|array See WebPHandler::decodeHeader
|
|
*/
|
|
protected static function decodeLossyChunkHeader( $header ) {
|
|
// Bytes 0-3 are 'VP8 '
|
|
// Bytes 4-7 are the VP8 stream size
|
|
// Bytes 8-10 are the frame tag
|
|
// Bytes 11-13 are 0x9D 0x01 0x2A called the sync code
|
|
$syncCode = substr( $header, 11, 3 );
|
|
if ( $syncCode != "\x9D\x01\x2A" ) {
|
|
wfDebugLog( 'WebP', __METHOD__ . ': Invalid sync code: ' .
|
|
bin2hex( $syncCode ) );
|
|
return [];
|
|
}
|
|
// Bytes 14-17 are image size
|
|
$imageSize = unpack( 'v2', substr( $header, 14, 4 ) );
|
|
// Image sizes are 14 bit, 2 MSB are scaling parameters which are ignored here
|
|
return [
|
|
'compression' => 'lossy',
|
|
'width' => $imageSize[1] & 0x3FFF,
|
|
'height' => $imageSize[2] & 0x3FFF
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Decodes a lossless chunk header
|
|
* @param string $header First few bytes of the header, expected to be at least 13 bytes long
|
|
* @return bool|array See WebPHandler::decodeHeader
|
|
*/
|
|
public static function decodeLosslessChunkHeader( $header ) {
|
|
// Bytes 0-3 are 'VP8L'
|
|
// Bytes 4-7 are chunk stream size
|
|
// Byte 8 is 0x2F called the signature
|
|
if ( $header[8] != "\x2F" ) {
|
|
wfDebugLog( 'WebP', __METHOD__ . ': Invalid signature: ' .
|
|
bin2hex( $header[8] ) );
|
|
return [];
|
|
}
|
|
// Bytes 9-12 contain the image size
|
|
// Bits 0-13 are width-1; bits 15-27 are height-1
|
|
$imageSize = unpack( 'C4', substr( $header, 9, 4 ) );
|
|
return [
|
|
'compression' => 'lossless',
|
|
'width' => ( $imageSize[1] | ( ( $imageSize[2] & 0x3F ) << 8 ) ) + 1,
|
|
'height' => ( ( ( $imageSize[2] & 0xC0 ) >> 6 ) |
|
|
( $imageSize[3] << 2 ) | ( ( $imageSize[4] & 0x03 ) << 10 ) ) + 1
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Decodes an extended chunk header
|
|
* @param string $header First few bytes of the header, expected to be at least 18 bytes long
|
|
* @return bool|array See WebPHandler::decodeHeader
|
|
*/
|
|
public static function decodeExtendedChunkHeader( $header ) {
|
|
// Bytes 0-3 are 'VP8X'
|
|
// Byte 4-7 are chunk length
|
|
// Byte 8-11 are a flag bytes
|
|
$flags = unpack( 'c', substr( $header, 8, 1 ) );
|
|
|
|
// Byte 12-17 are image size (24 bits)
|
|
$width = unpack( 'V', substr( $header, 12, 3 ) . "\x00" );
|
|
$height = unpack( 'V', substr( $header, 15, 3 ) . "\x00" );
|
|
|
|
return [
|
|
'compression' => 'unknown',
|
|
'animated' => ( $flags[1] & self::VP8X_ANIM ) == self::VP8X_ANIM,
|
|
'transparency' => ( $flags[1] & self::VP8X_ALPHA ) == self::VP8X_ALPHA,
|
|
'width' => ( $width[1] & 0xFFFFFF ) + 1,
|
|
'height' => ( $height[1] & 0xFFFFFF ) + 1
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @param File $file
|
|
* @return bool True, not all browsers support WebP
|
|
*/
|
|
public function mustRender( $file ) {
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* @param File $file
|
|
* @return bool False if we are unable to render this image
|
|
*/
|
|
public function canRender( $file ) {
|
|
if ( self::isAnimatedImage( $file ) ) {
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* @param File $image
|
|
* @return bool
|
|
*/
|
|
public function isAnimatedImage( $image ) {
|
|
$metadata = $image->getMetadataArray();
|
|
if ( isset( $metadata['animated'] ) && $metadata['animated'] === true ) {
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
public function canAnimateThumbnail( $file ) {
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Render files as PNG
|
|
*
|
|
* @param string $ext
|
|
* @param string $mime
|
|
* @param array|null $params
|
|
* @return array
|
|
*/
|
|
public function getThumbType( $ext, $mime, $params = null ) {
|
|
return [ 'png', 'image/png' ];
|
|
}
|
|
|
|
/**
|
|
* Must use "im" for XCF
|
|
*
|
|
* @param string $dstPath
|
|
* @param bool $checkDstPath
|
|
* @return string
|
|
*/
|
|
protected function getScalerType( $dstPath, $checkDstPath = true ) {
|
|
return 'im';
|
|
}
|
|
}
|