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 = false;
365 $retval = '';
366 if ( isset( $svgConverters[$svgConverter] ) ) {
367 if ( is_array( $svgConverters[$svgConverter] ) ) {
368 // This is a PHP callable
369 $func = $svgConverters[$svgConverter][0];
370 if ( !is_callable( $func ) ) {
371 throw new UnexpectedValueException( "$func is not callable" );
372 }
373 $err = $func( $srcPath,
374 $dstPath,
375 $width,
376 $height,
377 $lang,
378 ...array_slice( $svgConverters[$svgConverter], 1 )
379 );
380 $retval = (bool)$err;
381 } else {
382 // External command
383 $cmd = strtr( $svgConverters[$svgConverter], [
384 '$path/' => $svgConverterPath ? Shell::escape( "$svgConverterPath/" ) : '',
385 '$width' => (int)$width,
386 '$height' => (int)$height,
387 '$input' => Shell::escape( $srcPath ),
388 '$output' => Shell::escape( $dstPath ),
389 ] );
390
391 $env = [];
392 if ( $lang !== false ) {
393 $env['LANG'] = $lang;
394 }
395
396 wfDebug( __METHOD__ . ": $cmd" );
397 $err = wfShellExecWithStderr( $cmd, $retval, $env );
398 }
399 }
400 $removed = $this->removeBadFile( $dstPath, $retval );
401 if ( $retval != 0 || $removed ) {
402 // @phan-suppress-next-line PhanPossiblyUndeclaredVariable cmd is set when used
403 $this->logErrorForExternalProcess( $retval, $err, $cmd );
404 return new MediaTransformError( 'thumbnail_error', $width, $height, $err );
405 }
406
407 return true;
408 }
409
417 public static function rasterizeImagickExt( $srcPath, $dstPath, $width, $height ) {
418 $im = new Imagick( $srcPath );
419 $im->setBackgroundColor( 'transparent' );
420 $im->readImage( $srcPath );
421 $im->setImageFormat( 'png' );
422 $im->setImageDepth( 8 );
423
424 if ( !$im->thumbnailImage( (int)$width, (int)$height, /* fit */ false ) ) {
425 return 'Could not resize image';
426 }
427 if ( !$im->writeImage( $dstPath ) ) {
428 return "Could not write to $dstPath";
429 }
430 }
431
441 protected function getClientScalingThumbnailImage( $image, $params ) {
442 $url = $image->modifyClientThumbUrl( $image->getUrl(), $params );
443 return new ThumbnailImage( $image, $url, null, $params );
444 }
445
447 public function getThumbType( $ext, $mime, $params = null ) {
448 return [ 'png', 'image/png' ];
449 }
450
460 public function getLongDesc( $file ) {
461 $metadata = $this->validateMetadata( $file->getMetadataArray() );
462 if ( isset( $metadata['error'] ) ) {
463 return wfMessage( 'svg-long-error', $metadata['error']['message'] )
464 ->inLanguage( $this->getLanguage() )->escaped();
465 }
466
467 if ( $this->isAnimatedImage( $file ) ) {
468 $msg = wfMessage( 'svg-long-desc-animated' );
469 } else {
470 $msg = wfMessage( 'svg-long-desc' );
471 }
472
473 return $msg
474 ->numParams( $file->getWidth(), $file->getHeight() )
475 ->sizeParams( $file->getSize() )
476 ->inLanguage( $this->getLanguage() )
477 ->parse();
478 }
479
485 public function getSizeAndMetadata( $state, $filename ) {
486 $metadata = [ 'version' => self::SVG_METADATA_VERSION ];
487
488 try {
489 $svgReader = new SVGReader( $filename );
490 $metadata += $svgReader->getMetadata();
491 } catch ( InvalidSVGException $e ) {
492 // File not found, broken, etc.
493 $metadata['error'] = [
494 'message' => $e->getMessage(),
495 'code' => $e->getCode()
496 ];
497 wfDebug( __METHOD__ . ': ' . $e->getMessage() );
498 }
499
500 return [
501 'width' => $metadata['width'] ?? 0,
502 'height' => $metadata['height'] ?? 0,
503 'metadata' => $metadata
504 ];
505 }
506
508 protected function validateMetadata( $unser ) {
509 if ( isset( $unser['version'] ) && $unser['version'] === self::SVG_METADATA_VERSION ) {
510 return $unser;
511 }
512
513 return null;
514 }
515
517 public function getMetadataType( $image ) {
518 return 'parsed-svg';
519 }
520
522 public function isFileMetadataValid( $image ) {
523 $meta = $this->validateMetadata( $image->getMetadataArray() );
524 if ( !$meta ) {
525 return self::METADATA_BAD;
526 }
527 if ( !isset( $meta['originalWidth'] ) ) {
528 // Old but compatible
529 return self::METADATA_COMPATIBLE;
530 }
531
532 return self::METADATA_GOOD;
533 }
534
536 protected function visibleMetadataFields() {
537 return [ 'objectname', 'imagedescription' ];
538 }
539
545 public function formatMetadata( $file, $context = false ) {
546 $result = [
547 'visible' => [],
548 'collapsed' => []
549 ];
550 $metadata = $this->validateMetadata( $file->getMetadataArray() );
551 if ( !$metadata || isset( $metadata['error'] ) ) {
552 return false;
553 }
554
555 /* @todo Add a formatter
556 $format = new FormatSVG( $metadata );
557 $formatted = $format->getFormattedData();
558 */
559
560 // Sort fields into visible and collapsed
561 $visibleFields = $this->visibleMetadataFields();
562
563 $showMeta = false;
564 foreach ( $metadata as $name => $value ) {
565 $tag = strtolower( $name );
566 if ( isset( self::$metaConversion[$tag] ) ) {
567 $tag = strtolower( self::$metaConversion[$tag] );
568 } else {
569 // Do not output other metadata not in list
570 continue;
571 }
572 $showMeta = true;
573 self::addMeta( $result,
574 in_array( $tag, $visibleFields ) ? 'visible' : 'collapsed',
575 'exif',
576 $tag,
577 $value
578 );
579 }
580
581 return $showMeta ? $result : false;
582 }
583
589 public function validateParam( $name, $value ) {
590 if ( in_array( $name, [ 'width', 'height' ] ) ) {
591 // Reject negative heights, widths
592 return ( $value > 0 );
593 }
594 if ( $name === 'lang' ) {
595 // Validate $code
596 if ( $value === ''
597 || !LanguageCode::isWellFormedLanguageTag( $value )
598 ) {
599 return false;
600 }
601
602 return true;
603 }
604
605 // Only lang, width and height are acceptable keys
606 return false;
607 }
608
613 public function makeParamString( $params ) {
614 $lang = '';
615 $code = $this->getLanguageFromParams( $params );
616 if ( $code !== self::SVG_DEFAULT_RENDER_LANG ) {
617 $lang = 'lang' . strtolower( $code ) . '-';
618 }
619
620 if ( isset( $params['physicalWidth'] ) && $params['physicalWidth'] ) {
621 return "$lang{$params['physicalWidth']}px";
622 }
623
624 if ( !isset( $params['width'] ) ) {
625 return false;
626 }
627
628 return "$lang{$params['width']}px";
629 }
630
632 public function parseParamString( $str ) {
633 $m = false;
634 // Language codes are supposed to be lowercase
635 if ( preg_match( '/^lang([a-z]+(?:-[a-z]+)*)-(\d+)px$/', $str, $m ) ) {
636 if ( LanguageCode::isWellFormedLanguageTag( $m[1] ) ) {
637 return [ 'width' => array_pop( $m ), 'lang' => $m[1] ];
638 }
639 return [ 'width' => array_pop( $m ), 'lang' => self::SVG_DEFAULT_RENDER_LANG ];
640 }
641 if ( preg_match( '/^(\d+)px$/', $str, $m ) ) {
642 return [ 'width' => $m[1], 'lang' => self::SVG_DEFAULT_RENDER_LANG ];
643 }
644 return false;
645 }
646
648 public function getParamMap() {
649 return [ 'img_lang' => 'lang', 'img_width' => 'width' ];
650 }
651
656 protected function getScriptParams( $params ) {
657 $scriptParams = [ 'width' => $params['width'] ];
658 if ( isset( $params['lang'] ) ) {
659 $scriptParams['lang'] = $params['lang'];
660 }
661
662 return $scriptParams;
663 }
664
666 public function getCommonMetaArray( File $file ) {
667 $metadata = $this->validateMetadata( $file->getMetadataArray() );
668 if ( !$metadata || isset( $metadata['error'] ) ) {
669 return [];
670 }
671 $stdMetadata = [];
672 foreach ( $metadata as $name => $value ) {
673 $tag = strtolower( $name );
674 if ( $tag === 'originalwidth' || $tag === 'originalheight' ) {
675 // Skip these. In the exif metadata stuff, it is assumed these
676 // are measured in px, which is not the case here.
677 continue;
678 }
679 if ( isset( self::$metaConversion[$tag] ) ) {
680 $tag = self::$metaConversion[$tag];
681 $stdMetadata[$tag] = $value;
682 }
683 }
684
685 return $stdMetadata;
686 }
687}
688
690class_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.
wfShellExecWithStderr( $cmd, &$retval=null, $environ=[], $limits=[])
Execute a shell command, returning both stdout and stderr.
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:829
getSize()
Return the size of the image file, in bytes Overridden by LocalFile, UnregisteredLocalFile STUB.
Definition File.php:903
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.