MediaWiki master
SvgHandler.php
Go to the documentation of this file.
1<?php
10namespace MediaWiki\Media;
11
12use Imagick;
19use UnexpectedValueException;
20use Wikimedia\ScopedCallback;
21
27class SvgHandler extends ImageHandler {
28 public const SVG_METADATA_VERSION = 2;
29
30 private const SVG_DEFAULT_RENDER_LANG = 'en';
31
36 private static $metaConversion = [
37 'originalwidth' => 'ImageWidth',
38 'originalheight' => 'ImageLength',
39 'description' => 'ImageDescription',
40 'title' => 'ObjectName',
41 ];
42
44 public function isEnabled() {
45 $config = MediaWikiServices::getInstance()->getMainConfig();
46 $svgConverters = $config->get( MainConfigNames::SVGConverters );
47 $svgConverter = $config->get( MainConfigNames::SVGConverter );
48 if ( $config->get( MainConfigNames::SVGNativeRendering ) === true ) {
49 return true;
50 }
51 if ( !isset( $svgConverters[$svgConverter] ) ) {
52 wfDebug( "\$wgSVGConverter is invalid, disabling SVG rendering." );
53
54 return false;
55 }
56
57 return true;
58 }
59
60 public function allowRenderingByUserAgent( File $file ): bool {
61 $svgNativeRendering = MediaWikiServices::getInstance()
62 ->getMainConfig()->get( MainConfigNames::SVGNativeRendering );
63 if ( $svgNativeRendering === false ) {
64 // SVG images are always rasterized to PNG
65 return false;
66 }
67
68 // Files bigger than the limit have to be rendered as PNG, as big files might be a tax on the user agent
69 $maxSVGFilesize = MediaWikiServices::getInstance()
71 if ( $maxSVGFilesize && $file->getSize() >= $maxSVGFilesize ) {
72 return false;
73 }
74
75 if ( $svgNativeRendering === true ) {
76 return true;
77 }
78
79 // 'partial' mode: only allow if considered safe
80 // Browsers don't really support SVG translations, so always render those to PNG
81 if ( $svgNativeRendering === 'partial' ) {
82 return count( $this->getAvailableLanguages( $file ) ) <= 1;
83 }
84 return false;
85 }
86
88 public function mustRender( $file ) {
89 return !$this->allowRenderingByUserAgent( $file );
90 }
91
93 public function isVectorized( $file ) {
94 return true;
95 }
96
101 public function isAnimatedImage( $file ) {
102 # @todo Detect animated SVGs
103 $metadata = $this->validateMetadata( $file->getMetadataArray() );
104 if ( isset( $metadata['animated'] ) ) {
105 return $metadata['animated'];
106 }
107
108 return false;
109 }
110
123 public function getAvailableLanguages( File $file ) {
124 $langList = [];
125 $metadata = $this->validateMetadata( $file->getMetadataArray() );
126 if ( isset( $metadata['translations'] ) ) {
127 foreach ( $metadata['translations'] as $lang => $langType ) {
128 if ( $langType === SVGReader::LANG_FULL_MATCH ) {
129 $langList[] = strtolower( $lang );
130 }
131 }
132 }
133 return array_unique( $langList );
134 }
135
151 public function getMatchedLanguage( $userPreferredLanguage, array $svgLanguages ) {
152 // Explicitly requested undetermined language (text without svg systemLanguage attribute)
153 if ( $userPreferredLanguage === 'und' ) {
154 return 'und';
155 }
156 foreach ( $svgLanguages as $svgLang ) {
157 if ( strcasecmp( $svgLang, $userPreferredLanguage ) === 0 ) {
158 return $svgLang;
159 }
160 $trimmedSvgLang = $svgLang;
161 while ( str_contains( $trimmedSvgLang, '-' ) ) {
162 $trimmedSvgLang = substr( $trimmedSvgLang, 0, strrpos( $trimmedSvgLang, '-' ) );
163 if ( strcasecmp( $trimmedSvgLang, $userPreferredLanguage ) === 0 ) {
164 return $svgLang;
165 }
166 }
167 }
168 return null;
169 }
170
178 protected function getLanguageFromParams( array $params ) {
179 return $params['lang'] ?? $params['targetlang'] ?? self::SVG_DEFAULT_RENDER_LANG;
180 }
181
188 public function getDefaultRenderLanguage( File $file ) {
189 return self::SVG_DEFAULT_RENDER_LANG;
190 }
191
197 public function canAnimateThumbnail( $file ) {
198 return $this->allowRenderingByUserAgent( $file );
199 }
200
206 public function normaliseParams( $image, &$params ) {
207 if ( parent::normaliseParams( $image, $params ) ) {
208 $params = $this->normaliseParamsInternal( $image, $params );
209 return true;
210 }
211
212 return false;
213 }
214
224 protected function normaliseParamsInternal( $image, $params ) {
225 $svgMaxSize = MediaWikiServices::getInstance()->getMainConfig()->get( MainConfigNames::SVGMaxSize );
226
227 $srcWidth = $image->getWidth( $params['page'] );
228 $srcHeight = $image->getHeight( $params['page'] );
229 $params['physicalWidth'] = $this->getSteppedThumbWidth(
230 $image, $params['physicalWidth'], $srcWidth, $srcHeight
231 );
232 $params['physicalHeight'] = File::scaleHeight( $srcWidth, $srcHeight, $params['physicalWidth'] );
233
234 # Don't make an image bigger than wgMaxSVGSize on the smaller side
235 if ( $params['physicalWidth'] <= $params['physicalHeight'] ) {
236 if ( $params['physicalWidth'] > $svgMaxSize ) {
237 $params['physicalWidth'] = $svgMaxSize;
238 $params['physicalHeight'] = File::scaleHeight( $srcWidth, $srcHeight, $svgMaxSize );
239 }
240 } elseif ( $params['physicalHeight'] > $svgMaxSize ) {
241 $params['physicalWidth'] = File::scaleHeight( $srcHeight, $srcWidth, $svgMaxSize );
242 $params['physicalHeight'] = $svgMaxSize;
243 }
244 // To prevent the proliferation of thumbnails in languages not present in SVGs, unless
245 // explicitly forced by user.
246 if ( isset( $params['targetlang'] ) && !$image->getMatchedLanguage( $params['targetlang'] ) ) {
247 unset( $params['targetlang'] );
248 }
249
250 return $params;
251 }
252
261 public function doTransform( $image, $dstPath, $dstUrl, $params, $flags = 0 ) {
262 if ( !$this->normaliseParams( $image, $params ) ) {
263 return new TransformParameterError( $params );
264 }
265 $clientWidth = $params['width'];
266 $clientHeight = $params['height'];
267 $physicalWidth = $params['physicalWidth'];
268 $physicalHeight = $params['physicalHeight'];
269 $lang = $this->getLanguageFromParams( $params );
270
271 if ( $this->allowRenderingByUserAgent( $image ) ) {
272 return $this->getClientScalingThumbnailImage( $image, $params );
273 }
274
275 if ( $flags & self::TRANSFORM_LATER ) {
276 return new ThumbnailImage( $image, $dstUrl, $dstPath, $params );
277 }
278
279 $metadata = $this->validateMetadata( $image->getMetadataArray() );
280 if ( isset( $metadata['error'] ) ) {
281 $err = wfMessage( 'svg-long-error', $metadata['error']['message'] );
282
283 return new MediaTransformError( 'thumbnail_error', $clientWidth, $clientHeight, $err );
284 }
285
286 if ( !wfMkdirParents( dirname( $dstPath ), null, __METHOD__ ) ) {
287 return new MediaTransformError( 'thumbnail_error', $clientWidth, $clientHeight,
288 wfMessage( 'thumbnail_dest_directory' ) );
289 }
290
291 $srcPath = $image->getLocalRefPath();
292 if ( $srcPath === false ) { // Failed to get local copy
293 wfDebugLog( 'thumbnail',
294 sprintf( 'Thumbnail failed on %s: could not get local copy of "%s"',
295 wfHostname(), $image->getName() ) );
296
297 return new MediaTransformError( 'thumbnail_error',
298 $params['width'], $params['height'],
299 wfMessage( 'filemissing' )
300 );
301 }
302
303 // Make a temp dir with a symlink to the local copy in it.
304 // This plays well with rsvg-convert policy for external entities.
305 // https://git.gnome.org/browse/librsvg/commit/?id=f01aded72c38f0e18bc7ff67dee800e380251c8e
306 $tmpDir = wfTempDir() . '/svg_' . wfRandomString( 24 );
307 $lnPath = "$tmpDir/" . basename( $srcPath );
308 $ok = mkdir( $tmpDir, 0771 );
309 if ( !$ok ) {
310 wfDebugLog( 'thumbnail',
311 sprintf( 'Thumbnail failed on %s: could not create temporary directory %s',
312 wfHostname(), $tmpDir ) );
313 return new MediaTransformError( 'thumbnail_error',
314 $params['width'], $params['height'],
315 wfMessage( 'thumbnail-temp-create' )->text()
316 );
317 }
318 // phpcs:ignore Generic.PHP.NoSilencedErrors.Discouraged
319 $ok = @symlink( $srcPath, $lnPath );
321 $cleaner = new ScopedCallback( static function () use ( $tmpDir, $lnPath ) {
322 // phpcs:ignore Generic.PHP.NoSilencedErrors.Discouraged
323 @unlink( $lnPath );
324 // phpcs:ignore Generic.PHP.NoSilencedErrors.Discouraged
325 @rmdir( $tmpDir );
326 } );
327 if ( !$ok ) {
328 // Fallback because symlink often fails on Windows
329 $ok = copy( $srcPath, $lnPath );
330 }
331 if ( !$ok ) {
332 wfDebugLog( 'thumbnail',
333 sprintf( 'Thumbnail failed on %s: could not link %s to %s',
334 wfHostname(), $lnPath, $srcPath ) );
335 return new MediaTransformError( 'thumbnail_error',
336 $params['width'], $params['height'],
337 wfMessage( 'thumbnail-temp-create' )
338 );
339 }
340
341 $status = $this->rasterize( $lnPath, $dstPath, $physicalWidth, $physicalHeight, $lang );
342 if ( $status === true ) {
343 return new ThumbnailImage( $image, $dstUrl, $dstPath, $params );
344 }
345
346 return $status; // MediaTransformError
347 }
348
359 public function rasterize( $srcPath, $dstPath, $width, $height, $lang = false ) {
360 $mainConfig = MediaWikiServices::getInstance()->getMainConfig();
361 $svgConverters = $mainConfig->get( MainConfigNames::SVGConverters );
362 $svgConverter = $mainConfig->get( MainConfigNames::SVGConverter );
363 $svgConverterPath = $mainConfig->get( MainConfigNames::SVGConverterPath );
364 $err = '';
365 $retval = null;
366 $cmd = '';
367 if ( isset( $svgConverters[$svgConverter] ) ) {
368 // Handling largely for imagick PHP extension support (T16706) - ::rasterizeImagickExt()
369 if ( is_array( $svgConverters[$svgConverter] ) ) {
370 // This is a PHP callable
371 $func = $svgConverters[$svgConverter][0];
372 if ( !is_callable( $func ) ) {
373 throw new UnexpectedValueException( "$func is not callable" );
374 }
375 // Returns string on error else void
376 $err = $func( $srcPath,
377 $dstPath,
378 $width,
379 $height,
380 $lang,
381 ...array_slice( $svgConverters[$svgConverter], 1 )
382 );
383 $retval = is_string( $err ) ? 1 : 0;
384 $err = is_string( $err ) ? $err : '';
385 $cmd = 'PHP Callback: ' . (string)$func;
386 } else {
387 // External command
388 $cmd = strtr( $svgConverters[$svgConverter], [
389 '$path/' => $svgConverterPath ? Shell::escape( "$svgConverterPath/" ) : '',
390 '$width' => (int)$width,
391 '$height' => (int)$height,
392 '$input' => Shell::escape( $srcPath ),
393 '$output' => Shell::escape( $dstPath ),
394 ] );
395
396 $env = [];
397 if ( $lang !== false ) {
398 $env['LANG'] = $lang;
399 }
400
401 wfDebug( __METHOD__ . ": $cmd" );
402 $err = Shell::command()->unsafeCommand( $cmd )->environment( $env )->execute();
403 $retval = $err->getExitCode();
404 $err = $err->getStderr();
405 $err = $err === null ? '' : $err;
406 }
407 }
408 $removed = $this->removeBadFile( $dstPath, (int)$retval );
409 if ( ( $retval != 0 || $removed ) && $retval !== null ) {
410 $this->logErrorForExternalProcess( $retval, $err, $cmd );
411 return new MediaTransformError( 'thumbnail_error', $width, $height, $err );
412 }
413
414 return true;
415 }
416
424 public static function rasterizeImagickExt( $srcPath, $dstPath, $width, $height ) {
425 $im = new Imagick( $srcPath );
426 $im->setBackgroundColor( 'transparent' );
427 $im->readImage( $srcPath );
428 $im->setImageFormat( 'png' );
429 $im->setImageDepth( 8 );
430
431 if ( !$im->thumbnailImage( (int)$width, (int)$height, /* fit */ false ) ) {
432 return 'Could not resize image';
433 }
434 if ( !$im->writeImage( $dstPath ) ) {
435 return "Could not write to $dstPath";
436 }
437 }
438
448 protected function getClientScalingThumbnailImage( $image, $params ) {
449 $url = $image->modifyClientThumbUrl( $image->getUrl(), $params );
450 return new ThumbnailImage( $image, $url, null, $params );
451 }
452
454 public function getThumbType( $ext, $mime, $params = null ) {
455 return [ 'png', 'image/png' ];
456 }
457
467 public function getLongDesc( $file ) {
468 $metadata = $this->validateMetadata( $file->getMetadataArray() );
469 if ( isset( $metadata['error'] ) ) {
470 return wfMessage( 'svg-long-error', $metadata['error']['message'] )
471 ->inLanguage( $this->getLanguage() )->escaped();
472 }
473
474 if ( $this->isAnimatedImage( $file ) ) {
475 $msg = wfMessage( 'svg-long-desc-animated' );
476 } else {
477 $msg = wfMessage( 'svg-long-desc' );
478 }
479
480 return $msg
481 ->numParams( $file->getWidth(), $file->getHeight() )
482 ->sizeParams( $file->getSize() )
483 ->inLanguage( $this->getLanguage() )
484 ->parse();
485 }
486
492 public function getSizeAndMetadata( $state, $filename ) {
493 $metadata = [ 'version' => self::SVG_METADATA_VERSION ];
494
495 try {
496 $svgReader = new SVGReader( $filename );
497 $metadata += $svgReader->getMetadata();
498 } catch ( InvalidSVGException $e ) {
499 // File not found, broken, etc.
500 $metadata['error'] = [
501 'message' => $e->getMessage(),
502 'code' => $e->getCode()
503 ];
504 wfDebug( __METHOD__ . ': ' . $e->getMessage() );
505 }
506
507 return [
508 'width' => $metadata['width'] ?? 0,
509 'height' => $metadata['height'] ?? 0,
510 'metadata' => $metadata
511 ];
512 }
513
518 protected function validateMetadata( $unser ) {
519 if ( isset( $unser['version'] ) && $unser['version'] === self::SVG_METADATA_VERSION ) {
520 return $unser;
521 }
522
523 return null;
524 }
525
527 public function getMetadataType( $image ) {
528 return 'parsed-svg';
529 }
530
532 public function isFileMetadataValid( $image ) {
533 $meta = $this->validateMetadata( $image->getMetadataArray() );
534 if ( !$meta ) {
535 return self::METADATA_BAD;
536 }
537 if ( !isset( $meta['originalWidth'] ) ) {
538 // Old but compatible
539 return self::METADATA_COMPATIBLE;
540 }
541
542 return self::METADATA_GOOD;
543 }
544
546 protected function visibleMetadataFields() {
547 return [ 'objectname', 'imagedescription' ];
548 }
549
555 public function formatMetadata( $file, $context = false ) {
556 $result = [
557 'visible' => [],
558 'collapsed' => []
559 ];
560 $metadata = $this->validateMetadata( $file->getMetadataArray() );
561 if ( !$metadata || isset( $metadata['error'] ) ) {
562 return false;
563 }
564
565 /* @todo Add a formatter
566 $format = new FormatSVG( $metadata );
567 $formatted = $format->getFormattedData();
568 */
569
570 // Sort fields into visible and collapsed
571 $visibleFields = $this->visibleMetadataFields();
572
573 $showMeta = false;
574 foreach ( $metadata as $name => $value ) {
575 $tag = strtolower( $name );
576 if ( isset( self::$metaConversion[$tag] ) ) {
577 $tag = strtolower( self::$metaConversion[$tag] );
578 } else {
579 // Do not output other metadata not in list
580 continue;
581 }
582 $showMeta = true;
583 self::addMeta( $result,
584 in_array( $tag, $visibleFields ) ? 'visible' : 'collapsed',
585 'exif',
586 $tag,
587 $value
588 );
589 }
590
591 return $showMeta ? $result : false;
592 }
593
599 public function validateParam( $name, $value ) {
600 if ( in_array( $name, [ 'width', 'height' ] ) ) {
601 // Reject negative heights, widths
602 return (int)$value > 0;
603 }
604 if ( $name === 'lang' ) {
605 // Validate $code
606 if ( !is_string( $value ) || $value === ''
607 || !LanguageCode::isWellFormedLanguageTag( $value )
608 ) {
609 return false;
610 }
611
612 return true;
613 }
614
615 // Only lang, width and height are acceptable keys
616 return false;
617 }
618
623 public function makeParamString( $params ) {
624 $lang = '';
625 $code = $this->getLanguageFromParams( $params );
626 if ( $code !== self::SVG_DEFAULT_RENDER_LANG ) {
627 $lang = 'lang' . strtolower( $code ) . '-';
628 }
629
630 if ( isset( $params['physicalWidth'] ) && $params['physicalWidth'] ) {
631 return "$lang{$params['physicalWidth']}px";
632 }
633
634 if ( !isset( $params['width'] ) ) {
635 return false;
636 }
637
638 return "$lang{$params['width']}px";
639 }
640
642 public function parseParamString( $str ) {
643 $m = false;
644 // Language codes are supposed to be lowercase
645 if ( preg_match( '/^lang([a-z]+(?:-[a-z]+)*)-(\d+)px$/', $str, $m ) ) {
646 if ( LanguageCode::isWellFormedLanguageTag( $m[1] ) ) {
647 return [ 'width' => array_pop( $m ), 'lang' => $m[1] ];
648 }
649 return [ 'width' => array_pop( $m ), 'lang' => self::SVG_DEFAULT_RENDER_LANG ];
650 }
651 if ( preg_match( '/^(\d+)px$/', $str, $m ) ) {
652 return [ 'width' => $m[1], 'lang' => self::SVG_DEFAULT_RENDER_LANG ];
653 }
654 return false;
655 }
656
658 public function getParamMap() {
659 return [ 'img_lang' => 'lang', 'img_width' => 'width' ];
660 }
661
666 protected function getScriptParams( $params ) {
667 $scriptParams = [ 'width' => $params['width'] ];
668 if ( isset( $params['lang'] ) ) {
669 $scriptParams['lang'] = $params['lang'];
670 }
671
672 return $scriptParams;
673 }
674
676 public function getCommonMetaArray( File $file ) {
677 $metadata = $this->validateMetadata( $file->getMetadataArray() );
678 if ( !$metadata || isset( $metadata['error'] ) ) {
679 return [];
680 }
681 $stdMetadata = [];
682 foreach ( $metadata as $name => $value ) {
683 $tag = strtolower( $name );
684 if ( $tag === 'originalwidth' || $tag === 'originalheight' ) {
685 // Skip these. In the exif metadata stuff, it is assumed these
686 // are measured in px, which is not the case here.
687 continue;
688 }
689 if ( isset( self::$metaConversion[$tag] ) ) {
690 $tag = self::$metaConversion[$tag];
691 $stdMetadata[$tag] = $value;
692 }
693 }
694
695 return $stdMetadata;
696 }
697}
698
700class_alias( SvgHandler::class, 'SvgHandler' );
wfDebug( $text, $dest='all', array $context=[])
Sends a line to the debug log if enabled or, optionally, to a comment in output.
wfTempDir()
Tries to get the system directory for temporary files.
wfRandomString( $length=32)
Get a random string containing a number of pseudo-random hex characters.
wfHostname()
Get host name of the current machine, for use in error reporting.
wfDebugLog( $logGroup, $text, $dest='all', array $context=[])
Send a line to a supplementary debug log file, if configured, or main debug log if not.
wfMkdirParents( $dir, $mode=null, $caller=null)
Make directory, and make all parent directories if they don't exist.
wfMessage( $key,... $params)
This is the function for getting translated interface messages.
Implements some public methods and some protected utility functions which are required by multiple ch...
Definition File.php:80
getMetadataArray()
Get the unserialized handler-specific metadata STUB.
Definition File.php:830
getSize()
Return the size of the image file, in bytes Overridden by LocalFile, UnregisteredLocalFile STUB.
Definition File.php:904
getHeight( $page=1)
Return the height of the image.
Definition File.php:601
getWidth( $page=1)
Return the width of the image.
Definition File.php:586
Methods for dealing with language codes.
A class containing constants representing the names of configuration variables.
const SVGConverter
Name constant for the SVGConverter setting, for use with Config::get()
const SVGConverters
Name constant for the SVGConverters setting, for use with Config::get()
const SVGNativeRenderingSizeLimit
Name constant for the SVGNativeRenderingSizeLimit setting, for use with Config::get()
const SVGMaxSize
Name constant for the SVGMaxSize setting, for use with Config::get()
const SVGConverterPath
Name constant for the SVGConverterPath setting, for use with Config::get()
const SVGNativeRendering
Name constant for the SVGNativeRendering setting, for use with Config::get()
Service locator for MediaWiki core services.
getMainConfig()
Returns the Config object that provides configuration for MediaWiki core.
static getInstance()
Returns the global default instance of the top level service locator.
Media handler abstract base class for images.
Basic media transform error class.
Handler for SVG images.
getParamMap()
Get an associative array mapping magic word IDs to parameter names.Will be used by the parser to iden...
normaliseParamsInternal( $image, $params)
Code taken out of normaliseParams() for testability.
visibleMetadataFields()
Get a list of metadata items which should be displayed when the metadata table is collapsed....
formatMetadata( $file, $context=false)
static rasterizeImagickExt( $srcPath, $dstPath, $width, $height)
getCommonMetaArray(File $file)
Get an array of standard (FormatMetadata type) metadata values.The returned data is largely the same ...
getClientScalingThumbnailImage( $image, $params)
Get a ThumbnailImage that represents an image that will be scaled client side.
validateParam( $name, $value)
parseParamString( $str)
Parse a param string made with makeParamString back into an array.array|false Array of parameters or ...
isFileMetadataValid( $image)
Check if the metadata is valid for this handler.If it returns MediaHandler::METADATA_BAD (or false),...
doTransform( $image, $dstPath, $dstUrl, $params, $flags=0)
getLongDesc( $file)
Subtitle for the image.
isVectorized( $file)
The material is vectorized and thus scaling is lossless.to overridebool
allowRenderingByUserAgent(File $file)
getLanguageFromParams(array $params)
Determines render language from image parameters This is a lowercase IETF language.
getMetadataType( $image)
Get a string describing the type of metadata, for display purposes.to overrideThis method is currentl...
getAvailableLanguages(File $file)
Which languages (systemLanguage attribute) is supported.
canAnimateThumbnail( $file)
We do not support making animated svg thumbnails.
getSizeAndMetadata( $state, $filename)
normaliseParams( $image, &$params)
mustRender( $file)
True if handled types cannot be displayed directly in a browser but can be rendered....
getMatchedLanguage( $userPreferredLanguage, array $svgLanguages)
SVG's systemLanguage matching rules state: 'The systemLanguage attribute ... [e]valuates to "true" if...
getThumbType( $ext, $mime, $params=null)
Get the thumbnail extension and MIME type for a given source MIME type.to overridearray Thumbnail ext...
getDefaultRenderLanguage(File $file)
What language to render file in if none selected.
isEnabled()
False if the handler is disabled for all files.to overridebool
rasterize( $srcPath, $dstPath, $width, $height, $lang=false)
Transform an SVG file to PNG This function can be called outside of thumbnail contexts.
Media transform output for images.
Shortcut class for parameter validation errors.
Executes shell commands.
Definition Shell.php:32
Interface for objects which can provide a MediaWiki context on request.