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