Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
49.70% |
84 / 169 |
|
18.75% |
3 / 16 |
CRAP | |
0.00% |
0 / 1 |
| Image | |
49.70% |
84 / 169 |
|
18.75% |
3 / 16 |
470.38 | |
0.00% |
0 / 1 |
| __construct | |
81.25% |
26 / 32 |
|
0.00% |
0 / 1 |
9.53 | |||
| getName | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
| getModule | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
| getVariants | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
| getLangFallbacks | |
0.00% |
0 / 3 |
|
0.00% |
0 / 1 |
2 | |||
| getPath | |
93.75% |
15 / 16 |
|
0.00% |
0 / 1 |
8.02 | |||
| getLocalPath | |
66.67% |
2 / 3 |
|
0.00% |
0 / 1 |
2.15 | |||
| getExtension | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
3 | |||
| getMimeType | |
0.00% |
0 / 2 |
|
0.00% |
0 / 1 |
2 | |||
| getUrl | |
0.00% |
0 / 10 |
|
0.00% |
0 / 1 |
2 | |||
| getDataUri | |
0.00% |
0 / 3 |
|
0.00% |
0 / 1 |
2 | |||
| getImageData | |
71.43% |
15 / 21 |
|
0.00% |
0 / 1 |
12.33 | |||
| sendResponseHeaders | |
0.00% |
0 / 7 |
|
0.00% |
0 / 1 |
2 | |||
| variantize | |
100.00% |
15 / 15 |
|
100.00% |
1 / 1 |
6 | |||
| massageSvgPathdata | |
100.00% |
8 / 8 |
|
100.00% |
1 / 1 |
2 | |||
| rasterize | |
0.00% |
0 / 43 |
|
0.00% |
0 / 1 |
90 | |||
| 1 | <?php |
| 2 | /** |
| 3 | * @license GPL-2.0-or-later |
| 4 | * @file |
| 5 | */ |
| 6 | |
| 7 | namespace MediaWiki\ResourceLoader; |
| 8 | |
| 9 | use DOMDocument; |
| 10 | use InvalidArgumentException; |
| 11 | use InvalidSVGException; |
| 12 | use MediaWiki\Language\LanguageFallbackMode; |
| 13 | use MediaWiki\MainConfigNames; |
| 14 | use MediaWiki\MediaWikiServices; |
| 15 | use MediaWiki\Shell\Shell; |
| 16 | use RuntimeException; |
| 17 | use SvgHandler; |
| 18 | use SVGReader; |
| 19 | use Wikimedia\FileBackend\FileBackend; |
| 20 | use Wikimedia\Minify\CSSMin; |
| 21 | |
| 22 | /** |
| 23 | * Class encapsulating an image used in an ImageModule. |
| 24 | * |
| 25 | * @ingroup ResourceLoader |
| 26 | * @since 1.25 |
| 27 | */ |
| 28 | class Image { |
| 29 | /** |
| 30 | * Map of allowed file extensions to their MIME types. |
| 31 | */ |
| 32 | private const FILE_TYPES = [ |
| 33 | 'svg' => 'image/svg+xml', |
| 34 | 'png' => 'image/png', |
| 35 | 'gif' => 'image/gif', |
| 36 | 'jpg' => 'image/jpg', |
| 37 | ]; |
| 38 | |
| 39 | /** @var string */ |
| 40 | private $name; |
| 41 | /** @var string */ |
| 42 | private $module; |
| 43 | /** @var string|array */ |
| 44 | private $descriptor; |
| 45 | /** @var string */ |
| 46 | private $basePath; |
| 47 | /** @var array */ |
| 48 | private $variants; |
| 49 | /** @var string|null */ |
| 50 | private $defaultColor; |
| 51 | /** @var string */ |
| 52 | private $extension; |
| 53 | |
| 54 | /** |
| 55 | * @param string $name Self-name of the image as known to ImageModule. |
| 56 | * @param string $module Self-name of the module containing this image. |
| 57 | * Used to find the image in the registry e.g. through a load.php url. |
| 58 | * @param string|array $descriptor Path to image file, or array structure containing paths |
| 59 | * @param string $basePath Directory to which paths in descriptor refer |
| 60 | * @param array $variants |
| 61 | * @param string|null $defaultColor of the base variant |
| 62 | */ |
| 63 | public function __construct( $name, $module, $descriptor, $basePath, array $variants, |
| 64 | $defaultColor = null |
| 65 | ) { |
| 66 | $this->name = $name; |
| 67 | $this->module = $module; |
| 68 | $this->descriptor = $descriptor; |
| 69 | $this->basePath = $basePath; |
| 70 | $this->variants = $variants; |
| 71 | $this->defaultColor = $defaultColor; |
| 72 | |
| 73 | // Expand shorthands: |
| 74 | // [ "en,de,fr" => "foo.svg" ] |
| 75 | // → [ "en" => "foo.svg", "de" => "foo.svg", "fr" => "foo.svg" ] |
| 76 | if ( is_array( $this->descriptor ) && isset( $this->descriptor['lang'] ) ) { |
| 77 | foreach ( $this->descriptor['lang'] as $langList => $_ ) { |
| 78 | if ( str_contains( $langList, ',' ) ) { |
| 79 | $this->descriptor['lang'] += array_fill_keys( |
| 80 | explode( ',', $langList ), |
| 81 | $this->descriptor['lang'][$langList] |
| 82 | ); |
| 83 | unset( $this->descriptor['lang'][$langList] ); |
| 84 | } |
| 85 | } |
| 86 | } |
| 87 | // Remove 'deprecated' key |
| 88 | if ( is_array( $this->descriptor ) ) { |
| 89 | unset( $this->descriptor['deprecated'] ); |
| 90 | } |
| 91 | |
| 92 | // Ensure that all files have common extension. |
| 93 | $extensions = []; |
| 94 | $descriptor = is_array( $this->descriptor ) ? $this->descriptor : [ $this->descriptor ]; |
| 95 | array_walk_recursive( $descriptor, function ( $path ) use ( &$extensions ) { |
| 96 | $extensions[] = pathinfo( $this->getLocalPath( $path ), PATHINFO_EXTENSION ); |
| 97 | } ); |
| 98 | $extensions = array_unique( $extensions ); |
| 99 | if ( count( $extensions ) !== 1 ) { |
| 100 | throw new InvalidArgumentException( |
| 101 | "File type for different image files of '$name' not the same in module '$module'" |
| 102 | ); |
| 103 | } |
| 104 | $ext = $extensions[0]; |
| 105 | if ( !isset( self::FILE_TYPES[$ext] ) ) { |
| 106 | throw new InvalidArgumentException( |
| 107 | "Invalid file type for image files of '$name' (valid: svg, png, gif, jpg) in module '$module'" |
| 108 | ); |
| 109 | } |
| 110 | $this->extension = $ext; |
| 111 | } |
| 112 | |
| 113 | /** |
| 114 | * Get name of this image. |
| 115 | * |
| 116 | * @return string |
| 117 | */ |
| 118 | public function getName() { |
| 119 | return $this->name; |
| 120 | } |
| 121 | |
| 122 | /** |
| 123 | * Get name of the module this image belongs to. |
| 124 | * |
| 125 | * @return string |
| 126 | */ |
| 127 | public function getModule() { |
| 128 | return $this->module; |
| 129 | } |
| 130 | |
| 131 | /** |
| 132 | * Get the list of variants this image can be converted to. |
| 133 | * |
| 134 | * @return string[] |
| 135 | */ |
| 136 | public function getVariants(): array { |
| 137 | return array_keys( $this->variants ); |
| 138 | } |
| 139 | |
| 140 | /** |
| 141 | * @internal For unit testing override |
| 142 | * @param string $lang |
| 143 | * @return string[] |
| 144 | */ |
| 145 | protected function getLangFallbacks( string $lang ): array { |
| 146 | return MediaWikiServices::getInstance() |
| 147 | ->getLanguageFallback() |
| 148 | ->getAll( $lang, LanguageFallbackMode::STRICT ); |
| 149 | } |
| 150 | |
| 151 | /** |
| 152 | * Get the path to image file for given context. |
| 153 | * |
| 154 | * @param Context $context Any context |
| 155 | * @return string |
| 156 | */ |
| 157 | public function getPath( Context $context ) { |
| 158 | $desc = $this->descriptor; |
| 159 | if ( !is_array( $desc ) ) { |
| 160 | return $this->getLocalPath( $desc ); |
| 161 | } |
| 162 | if ( isset( $desc['lang'] ) ) { |
| 163 | $contextLang = $context->getLanguage(); |
| 164 | if ( isset( $desc['lang'][$contextLang] ) ) { |
| 165 | return $this->getLocalPath( $desc['lang'][$contextLang] ); |
| 166 | } |
| 167 | $fallbacks = $this->getLangFallbacks( $contextLang ); |
| 168 | foreach ( $fallbacks as $lang ) { |
| 169 | if ( isset( $desc['lang'][$lang] ) ) { |
| 170 | return $this->getLocalPath( $desc['lang'][$lang] ); |
| 171 | } |
| 172 | } |
| 173 | } |
| 174 | if ( isset( $desc[$context->getDirection()] ) ) { |
| 175 | return $this->getLocalPath( $desc[$context->getDirection()] ); |
| 176 | } |
| 177 | if ( isset( $desc['default'] ) ) { |
| 178 | return $this->getLocalPath( $desc['default'] ); |
| 179 | } |
| 180 | throw new RuntimeException( 'No matching path found' ); |
| 181 | } |
| 182 | |
| 183 | /** |
| 184 | * @param string|FilePath $path |
| 185 | * @return string |
| 186 | */ |
| 187 | protected function getLocalPath( $path ) { |
| 188 | if ( $path instanceof FilePath ) { |
| 189 | return $path->getLocalPath(); |
| 190 | } |
| 191 | |
| 192 | return "{$this->basePath}/$path"; |
| 193 | } |
| 194 | |
| 195 | /** |
| 196 | * Get the extension of the image. |
| 197 | * |
| 198 | * @param string|null $format Format to get the extension for, 'original' or 'rasterized' |
| 199 | * @return string Extension without leading dot, e.g. 'png' |
| 200 | */ |
| 201 | public function getExtension( $format = 'original' ) { |
| 202 | if ( $format === 'rasterized' && $this->extension === 'svg' ) { |
| 203 | return 'png'; |
| 204 | } |
| 205 | return $this->extension; |
| 206 | } |
| 207 | |
| 208 | /** |
| 209 | * Get the MIME type of the image. |
| 210 | * |
| 211 | * @param string|null $format Format to get the MIME type for, 'original' or 'rasterized' |
| 212 | * @return string |
| 213 | */ |
| 214 | public function getMimeType( $format = 'original' ) { |
| 215 | $ext = $this->getExtension( $format ); |
| 216 | return self::FILE_TYPES[$ext]; |
| 217 | } |
| 218 | |
| 219 | /** |
| 220 | * Get the load.php URL that will produce this image. |
| 221 | * |
| 222 | * @param Context $context Any context |
| 223 | * @param string $script URL to load.php |
| 224 | * @param string|null $variant Variant to get the URL for |
| 225 | * @param string $format Format to get the URL for, 'original' or 'rasterized' |
| 226 | * @return string URL |
| 227 | */ |
| 228 | public function getUrl( Context $context, $script, $variant, $format ) { |
| 229 | $query = [ |
| 230 | 'modules' => $this->getModule(), |
| 231 | 'image' => $this->getName(), |
| 232 | 'variant' => $variant, |
| 233 | 'format' => $format, |
| 234 | // Most images don't vary on language, but include the parameter anyway, so that version |
| 235 | // hashes are computed consistently. (T321394#9100166) |
| 236 | 'lang' => $context->getLanguage(), |
| 237 | 'skin' => $context->getSkin(), |
| 238 | 'version' => $context->getResourceLoader()->makeVersionQuery( $context, [ $this->getModule() ] ), |
| 239 | ]; |
| 240 | |
| 241 | return wfAppendQuery( $script, $query ); |
| 242 | } |
| 243 | |
| 244 | /** |
| 245 | * Get the data: URI that will produce this image. |
| 246 | * |
| 247 | * @param Context $context Any context |
| 248 | * @param string|null $variant Variant to get the URI for |
| 249 | * @param string $format Format to get the URI for, 'original' or 'rasterized' |
| 250 | * @return string |
| 251 | */ |
| 252 | public function getDataUri( Context $context, $variant, $format ) { |
| 253 | $type = $this->getMimeType( $format ); |
| 254 | $contents = $this->getImageData( $context, $variant, $format ); |
| 255 | return CSSMin::encodeStringAsDataURI( $contents, $type ); |
| 256 | } |
| 257 | |
| 258 | /** |
| 259 | * Get actual image data for this image. This can be saved to a file or sent to the browser to |
| 260 | * produce the converted image. |
| 261 | * |
| 262 | * Call getExtension() or getMimeType() with the same $format argument to learn what file type the |
| 263 | * returned data uses. |
| 264 | * |
| 265 | * @param Context $context Image context, or any context if $variant and $format |
| 266 | * given. |
| 267 | * @param string|null|false $variant Variant to get the data for. Optional; if given, overrides the data |
| 268 | * from $context. |
| 269 | * @param string|false $format Format to get the data for, 'original' or 'rasterized'. Optional; if |
| 270 | * given, overrides the data from $context. |
| 271 | * @return string|false Possibly binary image data, or false on failure |
| 272 | */ |
| 273 | public function getImageData( Context $context, $variant = false, $format = false ) { |
| 274 | if ( $variant === false ) { |
| 275 | $variant = $context->getVariant(); |
| 276 | } |
| 277 | if ( $format === false ) { |
| 278 | $format = $context->getFormat(); |
| 279 | } |
| 280 | |
| 281 | $path = $this->getPath( $context ); |
| 282 | if ( !file_exists( $path ) ) { |
| 283 | throw new RuntimeException( "File '$path' does not exist" ); |
| 284 | } |
| 285 | |
| 286 | if ( $this->getExtension() !== 'svg' ) { |
| 287 | return file_get_contents( $path ); |
| 288 | } |
| 289 | |
| 290 | if ( $variant && isset( $this->variants[$variant] ) ) { |
| 291 | $data = $this->variantize( $this->variants[$variant], $context ); |
| 292 | } else { |
| 293 | $defaultColor = $this->defaultColor; |
| 294 | $data = $defaultColor ? |
| 295 | $this->variantize( [ 'color' => $defaultColor ], $context ) : |
| 296 | file_get_contents( $path ); |
| 297 | } |
| 298 | |
| 299 | if ( $format === 'rasterized' ) { |
| 300 | $data = $this->rasterize( $data ); |
| 301 | if ( !$data ) { |
| 302 | $logger = $context->getResourceLoader()->getLogger(); |
| 303 | $logger->error( __METHOD__ . " failed to rasterize for $path" ); |
| 304 | } |
| 305 | } |
| 306 | |
| 307 | return $data; |
| 308 | } |
| 309 | |
| 310 | /** |
| 311 | * Send response headers (using the header() function) that are necessary to correctly serve the |
| 312 | * image data for this image, as returned by getImageData(). |
| 313 | * |
| 314 | * Note that the headers are independent of the language or image variant. |
| 315 | * |
| 316 | * @param Context $context Image context |
| 317 | */ |
| 318 | public function sendResponseHeaders( Context $context ): void { |
| 319 | $format = $context->getFormat(); |
| 320 | $mime = $this->getMimeType( $format ); |
| 321 | $filename = $this->getName() . '.' . $this->getExtension( $format ); |
| 322 | |
| 323 | header( 'Content-Type: ' . $mime ); |
| 324 | header( 'Content-Disposition: ' . |
| 325 | FileBackend::makeContentDisposition( 'inline', $filename ) ); |
| 326 | header( 'Access-Control-Allow-Origin: *' ); |
| 327 | } |
| 328 | |
| 329 | /** |
| 330 | * Convert this image, which is assumed to be SVG, to given variant. |
| 331 | * |
| 332 | * @param array $variantConf Array with a 'color' key, its value will be used as fill color |
| 333 | * @param Context $context Image context |
| 334 | * @return string New SVG file data |
| 335 | */ |
| 336 | protected function variantize( array $variantConf, Context $context ) { |
| 337 | $dom = new DOMDocument; |
| 338 | $dom->loadXML( file_get_contents( $this->getPath( $context ) ) ); |
| 339 | $root = $dom->documentElement; |
| 340 | $titleNode = null; |
| 341 | $wrapper = $dom->createElementNS( 'http://www.w3.org/2000/svg', 'g' ); |
| 342 | // Reattach all direct children of the `<svg>` root node to the `<g>` wrapper |
| 343 | while ( $root->firstChild ) { |
| 344 | $node = $root->firstChild; |
| 345 | '@phan-var \DOMElement $node'; /** @var \DOMElement $node */ |
| 346 | if ( !$titleNode && $node->nodeType === XML_ELEMENT_NODE && $node->tagName === 'title' ) { |
| 347 | // Remember the first encountered `<title>` node |
| 348 | $titleNode = $node; |
| 349 | } |
| 350 | $wrapper->appendChild( $node ); |
| 351 | } |
| 352 | if ( $titleNode ) { |
| 353 | // Reattach the `<title>` node to the `<svg>` root node rather than the `<g>` wrapper |
| 354 | $root->appendChild( $titleNode ); |
| 355 | } |
| 356 | $root->appendChild( $wrapper ); |
| 357 | $wrapper->setAttribute( 'fill', $variantConf['color'] ); |
| 358 | return $dom->saveXML(); |
| 359 | } |
| 360 | |
| 361 | /** |
| 362 | * Massage the SVG image data for converters which don't understand some path data syntax. |
| 363 | * |
| 364 | * This is necessary for rsvg and ImageMagick when compiled with rsvg support. |
| 365 | * Upstream bug is https://bugzilla.gnome.org/show_bug.cgi?id=620923, fixed 2014-11-10, so |
| 366 | * this will be needed for a while. (T76852) |
| 367 | * |
| 368 | * @param string $svg SVG image data |
| 369 | * @return string Massaged SVG image data |
| 370 | */ |
| 371 | protected function massageSvgPathdata( $svg ) { |
| 372 | $dom = new DOMDocument; |
| 373 | $dom->loadXML( $svg ); |
| 374 | foreach ( $dom->getElementsByTagName( 'path' ) as $node ) { |
| 375 | $pathData = $node->getAttribute( 'd' ); |
| 376 | // Make sure there is at least one space between numbers, and that leading zero is not omitted. |
| 377 | // rsvg has issues with syntax like "M-1-2" and "M.445.483" and especially "M-.445-.483". |
| 378 | $pathData = preg_replace( '/(-?)(\d*\.\d+|\d+)/', ' ${1}0$2 ', $pathData ); |
| 379 | // Strip unnecessary leading zeroes for prettiness, not strictly necessary |
| 380 | $pathData = preg_replace( '/([ -])0(\d)/', '$1$2', $pathData ); |
| 381 | $node->setAttribute( 'd', $pathData ); |
| 382 | } |
| 383 | return $dom->saveXML(); |
| 384 | } |
| 385 | |
| 386 | /** |
| 387 | * Convert passed image data, which is assumed to be SVG, to PNG. |
| 388 | * |
| 389 | * @param string $svg SVG image data |
| 390 | * @return string|bool PNG image data, or false on failure |
| 391 | */ |
| 392 | protected function rasterize( $svg ) { |
| 393 | $svgConverter = MediaWikiServices::getInstance()->getMainConfig()->get( MainConfigNames::SVGConverter ); |
| 394 | $svgConverterPath = MediaWikiServices::getInstance()->getMainConfig()->get( MainConfigNames::SVGConverterPath ); |
| 395 | // This code should be factored out to a separate method on SvgHandler, or perhaps a separate |
| 396 | // class, with a separate set of configuration settings. |
| 397 | // |
| 398 | // This is a distinct use case from regular SVG rasterization: |
| 399 | // * We can skip many checks (as the images come from a trusted source, |
| 400 | // rather than from the user). |
| 401 | // * We need to provide extra options to some converters to achieve acceptable quality for very |
| 402 | // small images, which might cause performance issues in the general case. |
| 403 | // * We want to directly pass image data to the converter, rather than a file path. |
| 404 | // |
| 405 | // See https://phabricator.wikimedia.org/T76473#801446 for examples of what happens with the |
| 406 | // default settings. |
| 407 | // |
| 408 | // For now, we special-case rsvg (used in WMF production) and do a messy workaround for other |
| 409 | // converters. |
| 410 | |
| 411 | $svg = $this->massageSvgPathdata( $svg ); |
| 412 | |
| 413 | // Sometimes this might be 'rsvg-secure'. Long as it's rsvg. |
| 414 | if ( str_starts_with( $svgConverter, 'rsvg' ) ) { |
| 415 | $command = 'rsvg-convert'; |
| 416 | if ( $svgConverterPath ) { |
| 417 | $command = Shell::escape( "{$svgConverterPath}/" ) . $command; |
| 418 | } |
| 419 | |
| 420 | $process = proc_open( |
| 421 | $command, |
| 422 | [ 0 => [ 'pipe', 'r' ], 1 => [ 'pipe', 'w' ] ], |
| 423 | $pipes |
| 424 | ); |
| 425 | |
| 426 | if ( $process ) { |
| 427 | fwrite( $pipes[0], $svg ); |
| 428 | fclose( $pipes[0] ); |
| 429 | $png = stream_get_contents( $pipes[1] ); |
| 430 | fclose( $pipes[1] ); |
| 431 | proc_close( $process ); |
| 432 | |
| 433 | return $png ?: false; |
| 434 | } |
| 435 | return false; |
| 436 | |
| 437 | } |
| 438 | // Write input to and read output from a temporary file |
| 439 | $tempFilenameSvg = tempnam( wfTempDir(), 'ResourceLoaderImage' ); |
| 440 | $tempFilenamePng = tempnam( wfTempDir(), 'ResourceLoaderImage' ); |
| 441 | |
| 442 | file_put_contents( $tempFilenameSvg, $svg ); |
| 443 | |
| 444 | try { |
| 445 | $svgReader = new SVGReader( $tempFilenameSvg ); |
| 446 | } catch ( InvalidSVGException $e ) { |
| 447 | // XXX Can this ever happen? |
| 448 | throw new RuntimeException( 'Invalid SVG', 0, $e ); |
| 449 | } |
| 450 | $metadata = $svgReader->getMetadata(); |
| 451 | if ( !isset( $metadata['width'] ) || !isset( $metadata['height'] ) ) { |
| 452 | unlink( $tempFilenameSvg ); |
| 453 | return false; |
| 454 | } |
| 455 | |
| 456 | $handler = new SvgHandler; |
| 457 | $res = $handler->rasterize( |
| 458 | $tempFilenameSvg, |
| 459 | $tempFilenamePng, |
| 460 | $metadata['width'], |
| 461 | $metadata['height'] |
| 462 | ); |
| 463 | unlink( $tempFilenameSvg ); |
| 464 | |
| 465 | if ( $res === true ) { |
| 466 | $png = file_get_contents( $tempFilenamePng ); |
| 467 | unlink( $tempFilenamePng ); |
| 468 | return $png; |
| 469 | } |
| 470 | |
| 471 | return false; |
| 472 | } |
| 473 | } |