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