MediaWiki master
SvgHandler.php
Go to the documentation of this file.
1<?php
10namespace MediaWiki\Media;
11
12use Imagick;
19use UnexpectedValueException;
20use Wikimedia\AtEase\AtEase;
21use Wikimedia\ScopedCallback;
22
28class SvgHandler extends ImageHandler {
29 public const SVG_METADATA_VERSION = 2;
30
31 private const SVG_DEFAULT_RENDER_LANG = 'en';
32
37 private static $metaConversion = [
38 'originalwidth' => 'ImageWidth',
39 'originalheight' => 'ImageLength',
40 'description' => 'ImageDescription',
41 'title' => 'ObjectName',
42 ];
43
45 public function isEnabled() {
46 $config = MediaWikiServices::getInstance()->getMainConfig();
47 $svgConverters = $config->get( MainConfigNames::SVGConverters );
48 $svgConverter = $config->get( MainConfigNames::SVGConverter );
49 if ( $config->get( MainConfigNames::SVGNativeRendering ) === true ) {
50 return true;
51 }
52 if ( !isset( $svgConverters[$svgConverter] ) ) {
53 wfDebug( "\$wgSVGConverter is invalid, disabling SVG rendering." );
54
55 return false;
56 }
57
58 return true;
59 }
60
65 public function allowRenderingByUserAgent( $file ) {
66 $svgNativeRendering = MediaWikiServices::getInstance()
67 ->getMainConfig()->get( MainConfigNames::SVGNativeRendering );
68 if ( $svgNativeRendering === true ) {
69 // Don't do any transform for any SVG.
70 return true;
71 }
72 if ( $svgNativeRendering !== 'partial' ) {
73 // SVG images are always rasterized to PNG
74 return false;
75 }
76 $maxSVGFilesize = MediaWikiServices::getInstance()
77 ->getMainConfig()->get( MainConfigNames::SVGNativeRenderingSizeLimit );
78 // Browsers don't really support SVG translations, so always render them to PNG
79 // Files bigger than the limit are also rendered as PNG, as big files might be a tax on the user agent
80 return count( $this->getAvailableLanguages( $file ) ) <= 1
81 && $file->getSize() <= $maxSVGFilesize;
82 }
83
85 public function mustRender( $file ) {
86 return !$this->allowRenderingByUserAgent( $file );
87 }
88
90 public function isVectorized( $file ) {
91 return true;
92 }
93
98 public function isAnimatedImage( $file ) {
99 # @todo Detect animated SVGs
100 $metadata = $this->validateMetadata( $file->getMetadataArray() );
101 if ( isset( $metadata['animated'] ) ) {
102 return $metadata['animated'];
103 }
104
105 return false;
106 }
107
120 public function getAvailableLanguages( File $file ) {
121 $langList = [];
122 $metadata = $this->validateMetadata( $file->getMetadataArray() );
123 if ( isset( $metadata['translations'] ) ) {
124 foreach ( $metadata['translations'] as $lang => $langType ) {
125 if ( $langType === SVGReader::LANG_FULL_MATCH ) {
126 $langList[] = strtolower( $lang );
127 }
128 }
129 }
130 return array_unique( $langList );
131 }
132
148 public function getMatchedLanguage( $userPreferredLanguage, array $svgLanguages ) {
149 // Explicitly requested undetermined language (text without svg systemLanguage attribute)
150 if ( $userPreferredLanguage === 'und' ) {
151 return 'und';
152 }
153 foreach ( $svgLanguages as $svgLang ) {
154 if ( strcasecmp( $svgLang, $userPreferredLanguage ) === 0 ) {
155 return $svgLang;
156 }
157 $trimmedSvgLang = $svgLang;
158 while ( str_contains( $trimmedSvgLang, '-' ) ) {
159 $trimmedSvgLang = substr( $trimmedSvgLang, 0, strrpos( $trimmedSvgLang, '-' ) );
160 if ( strcasecmp( $trimmedSvgLang, $userPreferredLanguage ) === 0 ) {
161 return $svgLang;
162 }
163 }
164 }
165 return null;
166 }
167
175 protected function getLanguageFromParams( array $params ) {
176 return $params['lang'] ?? $params['targetlang'] ?? self::SVG_DEFAULT_RENDER_LANG;
177 }
178
185 public function getDefaultRenderLanguage( File $file ) {
186 return self::SVG_DEFAULT_RENDER_LANG;
187 }
188
194 public function canAnimateThumbnail( $file ) {
195 return $this->allowRenderingByUserAgent( $file );
196 }
197
203 public function normaliseParams( $image, &$params ) {
204 if ( parent::normaliseParams( $image, $params ) ) {
205 $params = $this->normaliseParamsInternal( $image, $params );
206 return true;
207 }
208
209 return false;
210 }
211
221 protected function normaliseParamsInternal( $image, $params ) {
222 $svgMaxSize = MediaWikiServices::getInstance()->getMainConfig()->get( MainConfigNames::SVGMaxSize );
223
224 # Don't make an image bigger than wgMaxSVGSize on the smaller side
225 if ( $params['physicalWidth'] <= $params['physicalHeight'] ) {
226 if ( $params['physicalWidth'] > $svgMaxSize ) {
227 $srcWidth = $image->getWidth( $params['page'] );
228 $srcHeight = $image->getHeight( $params['page'] );
229 $params['physicalWidth'] = $svgMaxSize;
230 $params['physicalHeight'] = File::scaleHeight( $srcWidth, $srcHeight, $svgMaxSize );
231 }
232 } elseif ( $params['physicalHeight'] > $svgMaxSize ) {
233 $srcWidth = $image->getWidth( $params['page'] );
234 $srcHeight = $image->getHeight( $params['page'] );
235 $params['physicalWidth'] = File::scaleHeight( $srcHeight, $srcWidth, $svgMaxSize );
236 $params['physicalHeight'] = $svgMaxSize;
237 }
238 // To prevent the proliferation of thumbnails in languages not present in SVGs, unless
239 // explicitly forced by user.
240 if ( isset( $params['targetlang'] ) && !$image->getMatchedLanguage( $params['targetlang'] ) ) {
241 unset( $params['targetlang'] );
242 }
243
244 return $params;
245 }
246
255 public function doTransform( $image, $dstPath, $dstUrl, $params, $flags = 0 ) {
256 if ( !$this->normaliseParams( $image, $params ) ) {
257 return new TransformParameterError( $params );
258 }
259 $clientWidth = $params['width'];
260 $clientHeight = $params['height'];
261 $physicalWidth = $params['physicalWidth'];
262 $physicalHeight = $params['physicalHeight'];
263 $lang = $this->getLanguageFromParams( $params );
264
265 if ( $this->allowRenderingByUserAgent( $image ) ) {
266 // No transformation required for native rendering
267 return new ThumbnailImage( $image, $image->getURL(), false, $params );
268 }
269
270 if ( $flags & self::TRANSFORM_LATER ) {
271 return new ThumbnailImage( $image, $dstUrl, $dstPath, $params );
272 }
273
274 $metadata = $this->validateMetadata( $image->getMetadataArray() );
275 if ( isset( $metadata['error'] ) ) {
276 $err = wfMessage( 'svg-long-error', $metadata['error']['message'] );
277
278 return new MediaTransformError( 'thumbnail_error', $clientWidth, $clientHeight, $err );
279 }
280
281 if ( !wfMkdirParents( dirname( $dstPath ), null, __METHOD__ ) ) {
282 return new MediaTransformError( 'thumbnail_error', $clientWidth, $clientHeight,
283 wfMessage( 'thumbnail_dest_directory' ) );
284 }
285
286 $srcPath = $image->getLocalRefPath();
287 if ( $srcPath === false ) { // Failed to get local copy
288 wfDebugLog( 'thumbnail',
289 sprintf( 'Thumbnail failed on %s: could not get local copy of "%s"',
290 wfHostname(), $image->getName() ) );
291
292 return new MediaTransformError( 'thumbnail_error',
293 $params['width'], $params['height'],
294 wfMessage( 'filemissing' )
295 );
296 }
297
298 // Make a temp dir with a symlink to the local copy in it.
299 // This plays well with rsvg-convert policy for external entities.
300 // https://git.gnome.org/browse/librsvg/commit/?id=f01aded72c38f0e18bc7ff67dee800e380251c8e
301 $tmpDir = wfTempDir() . '/svg_' . wfRandomString( 24 );
302 $lnPath = "$tmpDir/" . basename( $srcPath );
303 $ok = mkdir( $tmpDir, 0771 );
304 if ( !$ok ) {
305 wfDebugLog( 'thumbnail',
306 sprintf( 'Thumbnail failed on %s: could not create temporary directory %s',
307 wfHostname(), $tmpDir ) );
308 return new MediaTransformError( 'thumbnail_error',
309 $params['width'], $params['height'],
310 wfMessage( 'thumbnail-temp-create' )->text()
311 );
312 }
313 // phpcs:ignore Generic.PHP.NoSilencedErrors.Discouraged
314 $ok = @symlink( $srcPath, $lnPath );
316 $cleaner = new ScopedCallback( static function () use ( $tmpDir, $lnPath ) {
317 AtEase::suppressWarnings();
318 unlink( $lnPath );
319 rmdir( $tmpDir );
320 AtEase::restoreWarnings();
321 } );
322 if ( !$ok ) {
323 // Fallback because symlink often fails on Windows
324 $ok = copy( $srcPath, $lnPath );
325 }
326 if ( !$ok ) {
327 wfDebugLog( 'thumbnail',
328 sprintf( 'Thumbnail failed on %s: could not link %s to %s',
329 wfHostname(), $lnPath, $srcPath ) );
330 return new MediaTransformError( 'thumbnail_error',
331 $params['width'], $params['height'],
332 wfMessage( 'thumbnail-temp-create' )
333 );
334 }
335
336 $status = $this->rasterize( $lnPath, $dstPath, $physicalWidth, $physicalHeight, $lang );
337 if ( $status === true ) {
338 return new ThumbnailImage( $image, $dstUrl, $dstPath, $params );
339 }
340
341 return $status; // MediaTransformError
342 }
343
354 public function rasterize( $srcPath, $dstPath, $width, $height, $lang = false ) {
355 $mainConfig = MediaWikiServices::getInstance()->getMainConfig();
356 $svgConverters = $mainConfig->get( MainConfigNames::SVGConverters );
357 $svgConverter = $mainConfig->get( MainConfigNames::SVGConverter );
358 $svgConverterPath = $mainConfig->get( MainConfigNames::SVGConverterPath );
359 $err = false;
360 $retval = '';
361 if ( isset( $svgConverters[$svgConverter] ) ) {
362 if ( is_array( $svgConverters[$svgConverter] ) ) {
363 // This is a PHP callable
364 $func = $svgConverters[$svgConverter][0];
365 if ( !is_callable( $func ) ) {
366 throw new UnexpectedValueException( "$func is not callable" );
367 }
368 $err = $func( $srcPath,
369 $dstPath,
370 $width,
371 $height,
372 $lang,
373 ...array_slice( $svgConverters[$svgConverter], 1 )
374 );
375 $retval = (bool)$err;
376 } else {
377 // External command
378 $cmd = strtr( $svgConverters[$svgConverter], [
379 '$path/' => $svgConverterPath ? Shell::escape( "$svgConverterPath/" ) : '',
380 '$width' => (int)$width,
381 '$height' => (int)$height,
382 '$input' => Shell::escape( $srcPath ),
383 '$output' => Shell::escape( $dstPath ),
384 ] );
385
386 $env = [];
387 if ( $lang !== false ) {
388 $env['LANG'] = $lang;
389 }
390
391 wfDebug( __METHOD__ . ": $cmd" );
392 $err = wfShellExecWithStderr( $cmd, $retval, $env );
393 }
394 }
395 $removed = $this->removeBadFile( $dstPath, $retval );
396 if ( $retval != 0 || $removed ) {
397 // @phan-suppress-next-next-line PhanPossiblyUndeclaredVariable cmd is set when used
398 // @phan-suppress-next-line PhanTypeMismatchArgumentNullable cmd is set when used
399 $this->logErrorForExternalProcess( $retval, $err, $cmd );
400 return new MediaTransformError( 'thumbnail_error', $width, $height, $err );
401 }
402
403 return true;
404 }
405
413 public static function rasterizeImagickExt( $srcPath, $dstPath, $width, $height ) {
414 $im = new Imagick( $srcPath );
415 $im->setBackgroundColor( 'transparent' );
416 $im->readImage( $srcPath );
417 $im->setImageFormat( 'png' );
418 $im->setImageDepth( 8 );
419
420 if ( !$im->thumbnailImage( (int)$width, (int)$height, /* fit */ false ) ) {
421 return 'Could not resize image';
422 }
423 if ( !$im->writeImage( $dstPath ) ) {
424 return "Could not write to $dstPath";
425 }
426 }
427
429 public function getThumbType( $ext, $mime, $params = null ) {
430 return [ 'png', 'image/png' ];
431 }
432
442 public function getLongDesc( $file ) {
443 $metadata = $this->validateMetadata( $file->getMetadataArray() );
444 if ( isset( $metadata['error'] ) ) {
445 return wfMessage( 'svg-long-error', $metadata['error']['message'] )->escaped();
446 }
447
448 if ( $this->isAnimatedImage( $file ) ) {
449 $msg = wfMessage( 'svg-long-desc-animated' );
450 } else {
451 $msg = wfMessage( 'svg-long-desc' );
452 }
453
454 return $msg
455 ->numParams( $file->getWidth(), $file->getHeight() )
456 ->sizeParams( $file->getSize() )
457 ->parse();
458 }
459
465 public function getSizeAndMetadata( $state, $filename ) {
466 $metadata = [ 'version' => self::SVG_METADATA_VERSION ];
467
468 try {
469 $svgReader = new SVGReader( $filename );
470 $metadata += $svgReader->getMetadata();
471 } catch ( InvalidSVGException $e ) {
472 // File not found, broken, etc.
473 $metadata['error'] = [
474 'message' => $e->getMessage(),
475 'code' => $e->getCode()
476 ];
477 wfDebug( __METHOD__ . ': ' . $e->getMessage() );
478 }
479
480 return [
481 'width' => $metadata['width'] ?? 0,
482 'height' => $metadata['height'] ?? 0,
483 'metadata' => $metadata
484 ];
485 }
486
488 protected function validateMetadata( $unser ) {
489 if ( isset( $unser['version'] ) && $unser['version'] === self::SVG_METADATA_VERSION ) {
490 return $unser;
491 }
492
493 return null;
494 }
495
497 public function getMetadataType( $image ) {
498 return 'parsed-svg';
499 }
500
502 public function isFileMetadataValid( $image ) {
503 $meta = $this->validateMetadata( $image->getMetadataArray() );
504 if ( !$meta ) {
505 return self::METADATA_BAD;
506 }
507 if ( !isset( $meta['originalWidth'] ) ) {
508 // Old but compatible
510 }
511
512 return self::METADATA_GOOD;
513 }
514
516 protected function visibleMetadataFields() {
517 return [ 'objectname', 'imagedescription' ];
518 }
519
525 public function formatMetadata( $file, $context = false ) {
526 $result = [
527 'visible' => [],
528 'collapsed' => []
529 ];
530 $metadata = $this->validateMetadata( $file->getMetadataArray() );
531 if ( !$metadata || isset( $metadata['error'] ) ) {
532 return false;
533 }
534
535 /* @todo Add a formatter
536 $format = new FormatSVG( $metadata );
537 $formatted = $format->getFormattedData();
538 */
539
540 // Sort fields into visible and collapsed
541 $visibleFields = $this->visibleMetadataFields();
542
543 $showMeta = false;
544 foreach ( $metadata as $name => $value ) {
545 $tag = strtolower( $name );
546 if ( isset( self::$metaConversion[$tag] ) ) {
547 $tag = strtolower( self::$metaConversion[$tag] );
548 } else {
549 // Do not output other metadata not in list
550 continue;
551 }
552 $showMeta = true;
553 self::addMeta( $result,
554 in_array( $tag, $visibleFields ) ? 'visible' : 'collapsed',
555 'exif',
556 $tag,
557 $value
558 );
559 }
560
561 return $showMeta ? $result : false;
562 }
563
569 public function validateParam( $name, $value ) {
570 if ( in_array( $name, [ 'width', 'height' ] ) ) {
571 // Reject negative heights, widths
572 return ( $value > 0 );
573 }
574 if ( $name === 'lang' ) {
575 // Validate $code
576 if ( $value === ''
577 || !LanguageCode::isWellFormedLanguageTag( $value )
578 ) {
579 return false;
580 }
581
582 return true;
583 }
584
585 // Only lang, width and height are acceptable keys
586 return false;
587 }
588
593 public function makeParamString( $params ) {
594 $lang = '';
595 $code = $this->getLanguageFromParams( $params );
596 if ( $code !== self::SVG_DEFAULT_RENDER_LANG ) {
597 $lang = 'lang' . strtolower( $code ) . '-';
598 }
599
600 if ( isset( $params['physicalWidth'] ) && $params['physicalWidth'] ) {
601 return "$lang{$params['physicalWidth']}px";
602 }
603
604 if ( !isset( $params['width'] ) ) {
605 return false;
606 }
607
608 return "$lang{$params['width']}px";
609 }
610
612 public function parseParamString( $str ) {
613 $m = false;
614 // Language codes are supposed to be lowercase
615 if ( preg_match( '/^lang([a-z]+(?:-[a-z]+)*)-(\d+)px$/', $str, $m ) ) {
616 if ( LanguageCode::isWellFormedLanguageTag( $m[1] ) ) {
617 return [ 'width' => array_pop( $m ), 'lang' => $m[1] ];
618 }
619 return [ 'width' => array_pop( $m ), 'lang' => self::SVG_DEFAULT_RENDER_LANG ];
620 }
621 if ( preg_match( '/^(\d+)px$/', $str, $m ) ) {
622 return [ 'width' => $m[1], 'lang' => self::SVG_DEFAULT_RENDER_LANG ];
623 }
624 return false;
625 }
626
628 public function getParamMap() {
629 return [ 'img_lang' => 'lang', 'img_width' => 'width' ];
630 }
631
636 protected function getScriptParams( $params ) {
637 $scriptParams = [ 'width' => $params['width'] ];
638 if ( isset( $params['lang'] ) ) {
639 $scriptParams['lang'] = $params['lang'];
640 }
641
642 return $scriptParams;
643 }
644
646 public function getCommonMetaArray( File $file ) {
647 $metadata = $this->validateMetadata( $file->getMetadataArray() );
648 if ( !$metadata || isset( $metadata['error'] ) ) {
649 return [];
650 }
651 $stdMetadata = [];
652 foreach ( $metadata as $name => $value ) {
653 $tag = strtolower( $name );
654 if ( $tag === 'originalwidth' || $tag === 'originalheight' ) {
655 // Skip these. In the exif metadata stuff, it is assumed these
656 // are measured in px, which is not the case here.
657 continue;
658 }
659 if ( isset( self::$metaConversion[$tag] ) ) {
660 $tag = self::$metaConversion[$tag];
661 $stdMetadata[$tag] = $value;
662 }
663 }
664
665 return $stdMetadata;
666 }
667}
668
670class_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:79
getMetadataArray()
Get the unserialized handler-specific metadata STUB.
Definition File.php:778
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.
static getInstance()
Returns the global default instance of the top level service locator.
Media handler abstract base class for images.
static addMeta(&$array, $visibility, $type, $id, $value, $param=false)
This is used to generate an array element for each metadata value That array is then used to generate...
removeBadFile( $dstPath, $retval=0)
Check for zero-sized thumbnails.
logErrorForExternalProcess( $retval, $err, $cmd)
Log an error that occurred in an external process.
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 ...
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
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.