MediaWiki master
SvgHandler.php
Go to the documentation of this file.
1<?php
30use Wikimedia\AtEase\AtEase;
31use Wikimedia\ScopedCallback;
32
38class SvgHandler extends ImageHandler {
39 public const SVG_METADATA_VERSION = 2;
40
41 private const SVG_DEFAULT_RENDER_LANG = 'en';
42
47 private static $metaConversion = [
48 'originalwidth' => 'ImageWidth',
49 'originalheight' => 'ImageLength',
50 'description' => 'ImageDescription',
51 'title' => 'ObjectName',
52 ];
53
54 public function isEnabled() {
55 $config = MediaWikiServices::getInstance()->getMainConfig();
56 $svgConverters = $config->get( MainConfigNames::SVGConverters );
57 $svgConverter = $config->get( MainConfigNames::SVGConverter );
58 if ( $config->get( MainConfigNames::SVGNativeRendering ) === true ) {
59 return true;
60 }
61 if ( !isset( $svgConverters[$svgConverter] ) ) {
62 wfDebug( "\$wgSVGConverter is invalid, disabling SVG rendering." );
63
64 return false;
65 }
66
67 return true;
68 }
69
70 public function allowRenderingByUserAgent( $file ) {
71 $svgNativeRendering = MediaWikiServices::getInstance()
72 ->getMainConfig()->get( MainConfigNames::SVGNativeRendering );
73 if ( $svgNativeRendering === true ) {
74 // Don't do any transform for any SVG.
75 return true;
76 }
77 if ( $svgNativeRendering !== 'partial' ) {
78 // SVG images are always rasterized to PNG
79 return false;
80 }
81 $maxSVGFilesize = MediaWikiServices::getInstance()
82 ->getMainConfig()->get( MainConfigNames::SVGNativeRenderingSizeLimit );
83 // Browsers don't really support SVG translations, so always render them to PNG
84 // Files bigger than the limit are also rendered as PNG, as big files might be a tax on the user agent
85 return count( $this->getAvailableLanguages( $file ) ) <= 1
86 && $file->getSize() <= $maxSVGFilesize;
87 }
88
89 public function mustRender( $file ) {
90 return !$this->allowRenderingByUserAgent( $file );
91 }
92
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 ( strpos( $trimmedSvgLang, '-' ) !== false ) {
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 # Don't make an image bigger than wgMaxSVGSize on the smaller side
228 if ( $params['physicalWidth'] <= $params['physicalHeight'] ) {
229 if ( $params['physicalWidth'] > $svgMaxSize ) {
230 $srcWidth = $image->getWidth( $params['page'] );
231 $srcHeight = $image->getHeight( $params['page'] );
232 $params['physicalWidth'] = $svgMaxSize;
233 $params['physicalHeight'] = File::scaleHeight( $srcWidth, $srcHeight, $svgMaxSize );
234 }
235 } elseif ( $params['physicalHeight'] > $svgMaxSize ) {
236 $srcWidth = $image->getWidth( $params['page'] );
237 $srcHeight = $image->getHeight( $params['page'] );
238 $params['physicalWidth'] = File::scaleHeight( $srcHeight, $srcWidth, $svgMaxSize );
239 $params['physicalHeight'] = $svgMaxSize;
240 }
241 // To prevent the proliferation of thumbnails in languages not present in SVGs, unless
242 // explicitly forced by user.
243 if ( isset( $params['targetlang'] ) && !$image->getMatchedLanguage( $params['targetlang'] ) ) {
244 unset( $params['targetlang'] );
245 }
246
247 return $params;
248 }
249
258 public function doTransform( $image, $dstPath, $dstUrl, $params, $flags = 0 ) {
259 if ( !$this->normaliseParams( $image, $params ) ) {
260 return new TransformParameterError( $params );
261 }
262 $clientWidth = $params['width'];
263 $clientHeight = $params['height'];
264 $physicalWidth = $params['physicalWidth'];
265 $physicalHeight = $params['physicalHeight'];
266 $lang = $this->getLanguageFromParams( $params );
267
268 if ( $this->allowRenderingByUserAgent( $image ) ) {
269 // No transformation required for native rendering
270 return new ThumbnailImage( $image, $image->getURL(), false, $params );
271 }
272
273 if ( $flags & self::TRANSFORM_LATER ) {
274 return new ThumbnailImage( $image, $dstUrl, $dstPath, $params );
275 }
276
277 $metadata = $this->validateMetadata( $image->getMetadataArray() );
278 if ( isset( $metadata['error'] ) ) {
279 $err = wfMessage( 'svg-long-error', $metadata['error']['message'] );
280
281 return new MediaTransformError( 'thumbnail_error', $clientWidth, $clientHeight, $err );
282 }
283
284 if ( !wfMkdirParents( dirname( $dstPath ), null, __METHOD__ ) ) {
285 return new MediaTransformError( 'thumbnail_error', $clientWidth, $clientHeight,
286 wfMessage( 'thumbnail_dest_directory' ) );
287 }
288
289 $srcPath = $image->getLocalRefPath();
290 if ( $srcPath === false ) { // Failed to get local copy
291 wfDebugLog( 'thumbnail',
292 sprintf( 'Thumbnail failed on %s: could not get local copy of "%s"',
293 wfHostname(), $image->getName() ) );
294
295 return new MediaTransformError( 'thumbnail_error',
296 $params['width'], $params['height'],
297 wfMessage( 'filemissing' )
298 );
299 }
300
301 // Make a temp dir with a symlink to the local copy in it.
302 // This plays well with rsvg-convert policy for external entities.
303 // https://git.gnome.org/browse/librsvg/commit/?id=f01aded72c38f0e18bc7ff67dee800e380251c8e
304 $tmpDir = wfTempDir() . '/svg_' . wfRandomString( 24 );
305 $lnPath = "$tmpDir/" . basename( $srcPath );
306 $ok = mkdir( $tmpDir, 0771 );
307 if ( !$ok ) {
308 wfDebugLog( 'thumbnail',
309 sprintf( 'Thumbnail failed on %s: could not create temporary directory %s',
310 wfHostname(), $tmpDir ) );
311 return new MediaTransformError( 'thumbnail_error',
312 $params['width'], $params['height'],
313 wfMessage( 'thumbnail-temp-create' )->text()
314 );
315 }
316 // phpcs:ignore Generic.PHP.NoSilencedErrors.Discouraged
317 $ok = @symlink( $srcPath, $lnPath );
319 $cleaner = new ScopedCallback( static function () use ( $tmpDir, $lnPath ) {
320 AtEase::suppressWarnings();
321 unlink( $lnPath );
322 rmdir( $tmpDir );
323 AtEase::restoreWarnings();
324 } );
325 if ( !$ok ) {
326 // Fallback because symlink often fails on Windows
327 $ok = copy( $srcPath, $lnPath );
328 }
329 if ( !$ok ) {
330 wfDebugLog( 'thumbnail',
331 sprintf( 'Thumbnail failed on %s: could not link %s to %s',
332 wfHostname(), $lnPath, $srcPath ) );
333 return new MediaTransformError( 'thumbnail_error',
334 $params['width'], $params['height'],
335 wfMessage( 'thumbnail-temp-create' )
336 );
337 }
338
339 $status = $this->rasterize( $lnPath, $dstPath, $physicalWidth, $physicalHeight, $lang );
340 if ( $status === true ) {
341 return new ThumbnailImage( $image, $dstUrl, $dstPath, $params );
342 }
343
344 return $status; // MediaTransformError
345 }
346
357 public function rasterize( $srcPath, $dstPath, $width, $height, $lang = false ) {
358 $mainConfig = MediaWikiServices::getInstance()->getMainConfig();
359 $svgConverters = $mainConfig->get( MainConfigNames::SVGConverters );
360 $svgConverter = $mainConfig->get( MainConfigNames::SVGConverter );
361 $svgConverterPath = $mainConfig->get( MainConfigNames::SVGConverterPath );
362 $err = false;
363 $retval = '';
364 if ( isset( $svgConverters[$svgConverter] ) ) {
365 if ( is_array( $svgConverters[$svgConverter] ) ) {
366 // This is a PHP callable
367 $func = $svgConverters[$svgConverter][0];
368 if ( !is_callable( $func ) ) {
369 throw new UnexpectedValueException( "$func is not callable" );
370 }
371 $err = $func( $srcPath,
372 $dstPath,
373 $width,
374 $height,
375 $lang,
376 ...array_slice( $svgConverters[$svgConverter], 1 )
377 );
378 $retval = (bool)$err;
379 } else {
380 // External command
381 $cmd = strtr( $svgConverters[$svgConverter], [
382 '$path/' => $svgConverterPath ? Shell::escape( "$svgConverterPath/" ) : '',
383 '$width' => (int)$width,
384 '$height' => (int)$height,
385 '$input' => Shell::escape( $srcPath ),
386 '$output' => Shell::escape( $dstPath ),
387 ] );
388
389 $env = [];
390 if ( $lang !== false ) {
391 $env['LANG'] = $lang;
392 }
393
394 wfDebug( __METHOD__ . ": $cmd" );
395 $err = wfShellExecWithStderr( $cmd, $retval, $env );
396 }
397 }
398 $removed = $this->removeBadFile( $dstPath, $retval );
399 if ( $retval != 0 || $removed ) {
400 // @phan-suppress-next-next-line PhanPossiblyUndeclaredVariable cmd is set when used
401 // @phan-suppress-next-line PhanTypeMismatchArgumentNullable cmd is set when used
402 $this->logErrorForExternalProcess( $retval, $err, $cmd );
403 return new MediaTransformError( 'thumbnail_error', $width, $height, $err );
404 }
405
406 return true;
407 }
408
409 public static function rasterizeImagickExt( $srcPath, $dstPath, $width, $height ) {
410 $im = new Imagick( $srcPath );
411 $im->setBackgroundColor( 'transparent' );
412 $im->readImage( $srcPath );
413 $im->setImageFormat( 'png' );
414 $im->setImageDepth( 8 );
415
416 if ( !$im->thumbnailImage( (int)$width, (int)$height, /* fit */ false ) ) {
417 return 'Could not resize image';
418 }
419 if ( !$im->writeImage( $dstPath ) ) {
420 return "Could not write to $dstPath";
421 }
422 }
423
424 public function getThumbType( $ext, $mime, $params = null ) {
425 return [ 'png', 'image/png' ];
426 }
427
437 public function getLongDesc( $file ) {
438 $metadata = $this->validateMetadata( $file->getMetadataArray() );
439 if ( isset( $metadata['error'] ) ) {
440 return wfMessage( 'svg-long-error', $metadata['error']['message'] )->text();
441 }
442
443 if ( $this->isAnimatedImage( $file ) ) {
444 $msg = wfMessage( 'svg-long-desc-animated' );
445 } else {
446 $msg = wfMessage( 'svg-long-desc' );
447 }
448
449 return $msg->numParams( $file->getWidth(), $file->getHeight() )->sizeParams( $file->getSize() )->parse();
450 }
451
457 public function getSizeAndMetadata( $state, $filename ) {
458 $metadata = [ 'version' => self::SVG_METADATA_VERSION ];
459
460 try {
461 $svgReader = new SVGReader( $filename );
462 $metadata += $svgReader->getMetadata();
463 } catch ( InvalidSVGException $e ) {
464 // File not found, broken, etc.
465 $metadata['error'] = [
466 'message' => $e->getMessage(),
467 'code' => $e->getCode()
468 ];
469 wfDebug( __METHOD__ . ': ' . $e->getMessage() );
470 }
471
472 return [
473 'width' => $metadata['width'] ?? 0,
474 'height' => $metadata['height'] ?? 0,
475 'metadata' => $metadata
476 ];
477 }
478
479 protected function validateMetadata( $unser ) {
480 if ( isset( $unser['version'] ) && $unser['version'] === self::SVG_METADATA_VERSION ) {
481 return $unser;
482 }
483
484 return null;
485 }
486
487 public function getMetadataType( $image ) {
488 return 'parsed-svg';
489 }
490
491 public function isFileMetadataValid( $image ) {
492 $meta = $this->validateMetadata( $image->getMetadataArray() );
493 if ( !$meta ) {
494 return self::METADATA_BAD;
495 }
496 if ( !isset( $meta['originalWidth'] ) ) {
497 // Old but compatible
498 return self::METADATA_COMPATIBLE;
499 }
500
501 return self::METADATA_GOOD;
502 }
503
504 protected function visibleMetadataFields() {
505 return [ 'objectname', 'imagedescription' ];
506 }
507
513 public function formatMetadata( $file, $context = false ) {
514 $result = [
515 'visible' => [],
516 'collapsed' => []
517 ];
518 $metadata = $this->validateMetadata( $file->getMetadataArray() );
519 if ( !$metadata || isset( $metadata['error'] ) ) {
520 return false;
521 }
522
523 /* @todo Add a formatter
524 $format = new FormatSVG( $metadata );
525 $formatted = $format->getFormattedData();
526 */
527
528 // Sort fields into visible and collapsed
529 $visibleFields = $this->visibleMetadataFields();
530
531 $showMeta = false;
532 foreach ( $metadata as $name => $value ) {
533 $tag = strtolower( $name );
534 if ( isset( self::$metaConversion[$tag] ) ) {
535 $tag = strtolower( self::$metaConversion[$tag] );
536 } else {
537 // Do not output other metadata not in list
538 continue;
539 }
540 $showMeta = true;
541 self::addMeta( $result,
542 in_array( $tag, $visibleFields ) ? 'visible' : 'collapsed',
543 'exif',
544 $tag,
545 $value
546 );
547 }
548
549 return $showMeta ? $result : false;
550 }
551
557 public function validateParam( $name, $value ) {
558 if ( in_array( $name, [ 'width', 'height' ] ) ) {
559 // Reject negative heights, widths
560 return ( $value > 0 );
561 }
562 if ( $name === 'lang' ) {
563 // Validate $code
564 if ( $value === ''
565 || !LanguageCode::isWellFormedLanguageTag( $value )
566 ) {
567 return false;
568 }
569
570 return true;
571 }
572
573 // Only lang, width and height are acceptable keys
574 return false;
575 }
576
581 public function makeParamString( $params ) {
582 $lang = '';
583 $code = $this->getLanguageFromParams( $params );
584 if ( $code !== self::SVG_DEFAULT_RENDER_LANG ) {
585 $lang = 'lang' . strtolower( $code ) . '-';
586 }
587
588 if ( isset( $params['physicalWidth'] ) && $params['physicalWidth'] ) {
589 return "$lang{$params['physicalWidth']}px";
590 }
591
592 if ( !isset( $params['width'] ) ) {
593 return false;
594 }
595
596 return "$lang{$params['width']}px";
597 }
598
599 public function parseParamString( $str ) {
600 $m = false;
601 // Language codes are supposed to be lowercase
602 if ( preg_match( '/^lang([a-z]+(?:-[a-z]+)*)-(\d+)px$/', $str, $m ) ) {
603 if ( LanguageCode::isWellFormedLanguageTag( $m[1] ) ) {
604 return [ 'width' => array_pop( $m ), 'lang' => $m[1] ];
605 }
606 return [ 'width' => array_pop( $m ), 'lang' => self::SVG_DEFAULT_RENDER_LANG ];
607 }
608 if ( preg_match( '/^(\d+)px$/', $str, $m ) ) {
609 return [ 'width' => $m[1], 'lang' => self::SVG_DEFAULT_RENDER_LANG ];
610 }
611 return false;
612 }
613
614 public function getParamMap() {
615 return [ 'img_lang' => 'lang', 'img_width' => 'width' ];
616 }
617
622 protected function getScriptParams( $params ) {
623 $scriptParams = [ 'width' => $params['width'] ];
624 if ( isset( $params['lang'] ) ) {
625 $scriptParams['lang'] = $params['lang'];
626 }
627
628 return $scriptParams;
629 }
630
631 public function getCommonMetaArray( File $file ) {
632 $metadata = $this->validateMetadata( $file->getMetadataArray() );
633 if ( !$metadata || isset( $metadata['error'] ) ) {
634 return [];
635 }
636 $stdMetadata = [];
637 foreach ( $metadata as $name => $value ) {
638 $tag = strtolower( $name );
639 if ( $tag === 'originalwidth' || $tag === 'originalheight' ) {
640 // Skip these. In the exif metadata stuff, it is assumed these
641 // are measured in px, which is not the case here.
642 continue;
643 }
644 if ( isset( self::$metaConversion[$tag] ) ) {
645 $tag = self::$metaConversion[$tag];
646 $stdMetadata[$tag] = $value;
647 }
648 }
649
650 return $stdMetadata;
651 }
652}
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.
Media handler abstract base class for images.
logErrorForExternalProcess( $retval, $err, $cmd)
Log an error that occurred in an external process.
removeBadFile( $dstPath, $retval=0)
Check for zero-sized thumbnails.
Basic media transform error class.
Implements some public methods and some protected utility functions which are required by multiple ch...
Definition File.php:93
getMetadataArray()
Get the unserialized handler-specific metadata STUB.
Definition File.php:791
Methods for dealing with language codes.
A class containing constants representing the names of configuration variables.
Service locator for MediaWiki core services.
Executes shell commands.
Definition Shell.php:46
Handler for SVG images.
validateParam( $name, $value)
isVectorized( $file)
The material is vectorized and thus scaling is lossless.
normaliseParams( $image, &$params)
formatMetadata( $file, $context=false)
parseParamString( $str)
Parse a param string made with makeParamString back into an array.array|false Array of parameters or ...
mustRender( $file)
True if handled types cannot be displayed directly in a browser but can be rendered.
getScriptParams( $params)
makeParamString( $params)
getCommonMetaArray(File $file)
Get an array of standard (FormatMetadata type) metadata values.
doTransform( $image, $dstPath, $dstUrl, $params, $flags=0)
validateMetadata( $unser)
getLanguageFromParams(array $params)
Determines render language from image parameters This is a lowercase IETF language.
getAvailableLanguages(File $file)
Which languages (systemLanguage attribute) is supported.
getLongDesc( $file)
Subtitle for the image.
normaliseParamsInternal( $image, $params)
Code taken out of normaliseParams() for testability.
getMetadataType( $image)
Get a string describing the type of metadata, for display purposes.
getThumbType( $ext, $mime, $params=null)
Get the thumbnail extension and MIME type for a given source MIME type.
getDefaultRenderLanguage(File $file)
What language to render file in if none selected.
rasterize( $srcPath, $dstPath, $width, $height, $lang=false)
Transform an SVG file to PNG This function can be called outside of thumbnail contexts.
getSizeAndMetadata( $state, $filename)
isEnabled()
False if the handler is disabled for all files.
canAnimateThumbnail( $file)
We do not support making animated svg thumbnails.
visibleMetadataFields()
Get a list of metadata items which should be displayed when the metadata table is collapsed.
static rasterizeImagickExt( $srcPath, $dstPath, $width, $height)
isFileMetadataValid( $image)
Check if the metadata is valid for this handler.
isAnimatedImage( $file)
getMatchedLanguage( $userPreferredLanguage, array $svgLanguages)
SVG's systemLanguage matching rules state: 'The systemLanguage attribute ... [e]valuates to "true" if...
const SVG_METADATA_VERSION
allowRenderingByUserAgent( $file)
getParamMap()
Get an associative array mapping magic word IDs to parameter names.Will be used by the parser to iden...
Media transform output for images.
Shortcut class for parameter validation errors.
Interface for objects which can provide a MediaWiki context on request.