MediaWiki master
SvgHandler.php
Go to the documentation of this file.
1<?php
27use Wikimedia\AtEase\AtEase;
28use Wikimedia\ScopedCallback;
29
35class SvgHandler extends ImageHandler {
36 public const SVG_METADATA_VERSION = 2;
37
38 private const SVG_DEFAULT_RENDER_LANG = 'en';
39
44 private static $metaConversion = [
45 'originalwidth' => 'ImageWidth',
46 'originalheight' => 'ImageLength',
47 'description' => 'ImageDescription',
48 'title' => 'ObjectName',
49 ];
50
51 public function isEnabled() {
52 $config = MediaWikiServices::getInstance()->getMainConfig();
53 $svgConverters = $config->get( MainConfigNames::SVGConverters );
54 $svgConverter = $config->get( MainConfigNames::SVGConverter );
55 if ( $config->get( MainConfigNames::SVGNativeRendering ) === true ) {
56 return true;
57 }
58 if ( !isset( $svgConverters[$svgConverter] ) ) {
59 wfDebug( "\$wgSVGConverter is invalid, disabling SVG rendering." );
60
61 return false;
62 }
63
64 return true;
65 }
66
67 public function allowRenderingByUserAgent( $file ) {
68 $svgNativeRendering = MediaWikiServices::getInstance()
69 ->getMainConfig()->get( MainConfigNames::SVGNativeRendering );
70 if ( $svgNativeRendering === true ) {
71 // Don't do any transform for any SVG.
72 return true;
73 }
74 if ( $svgNativeRendering !== 'partial' ) {
75 // SVG images are always rasterized to PNG
76 return false;
77 }
78 $maxSVGFilesize = MediaWikiServices::getInstance()
79 ->getMainConfig()->get( MainConfigNames::SVGNativeRenderingSizeLimit );
80 // Browsers don't really support SVG translations, so always render them to PNG
81 // Files bigger than the limit are also rendered as PNG, as big files might be a tax on the user agent
82 return count( $this->getAvailableLanguages( $file ) ) <= 1
83 && $file->getSize() <= $maxSVGFilesize;
84 }
85
86 public function mustRender( $file ) {
87 return !$this->allowRenderingByUserAgent( $file );
88 }
89
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 ( strpos( $trimmedSvgLang, '-' ) !== false ) {
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
406 public static function rasterizeImagickExt( $srcPath, $dstPath, $width, $height ) {
407 $im = new Imagick( $srcPath );
408 $im->setBackgroundColor( 'transparent' );
409 $im->readImage( $srcPath );
410 $im->setImageFormat( 'png' );
411 $im->setImageDepth( 8 );
412
413 if ( !$im->thumbnailImage( (int)$width, (int)$height, /* fit */ false ) ) {
414 return 'Could not resize image';
415 }
416 if ( !$im->writeImage( $dstPath ) ) {
417 return "Could not write to $dstPath";
418 }
419 }
420
421 public function getThumbType( $ext, $mime, $params = null ) {
422 return [ 'png', 'image/png' ];
423 }
424
434 public function getLongDesc( $file ) {
435 $metadata = $this->validateMetadata( $file->getMetadataArray() );
436 if ( isset( $metadata['error'] ) ) {
437 return wfMessage( 'svg-long-error', $metadata['error']['message'] )->text();
438 }
439
440 if ( $this->isAnimatedImage( $file ) ) {
441 $msg = wfMessage( 'svg-long-desc-animated' );
442 } else {
443 $msg = wfMessage( 'svg-long-desc' );
444 }
445
446 return $msg->numParams( $file->getWidth(), $file->getHeight() )->sizeParams( $file->getSize() )->parse();
447 }
448
454 public function getSizeAndMetadata( $state, $filename ) {
455 $metadata = [ 'version' => self::SVG_METADATA_VERSION ];
456
457 try {
458 $svgReader = new SVGReader( $filename );
459 $metadata += $svgReader->getMetadata();
460 } catch ( InvalidSVGException $e ) {
461 // File not found, broken, etc.
462 $metadata['error'] = [
463 'message' => $e->getMessage(),
464 'code' => $e->getCode()
465 ];
466 wfDebug( __METHOD__ . ': ' . $e->getMessage() );
467 }
468
469 return [
470 'width' => $metadata['width'] ?? 0,
471 'height' => $metadata['height'] ?? 0,
472 'metadata' => $metadata
473 ];
474 }
475
476 protected function validateMetadata( $unser ) {
477 if ( isset( $unser['version'] ) && $unser['version'] === self::SVG_METADATA_VERSION ) {
478 return $unser;
479 }
480
481 return null;
482 }
483
484 public function getMetadataType( $image ) {
485 return 'parsed-svg';
486 }
487
488 public function isFileMetadataValid( $image ) {
489 $meta = $this->validateMetadata( $image->getMetadataArray() );
490 if ( !$meta ) {
491 return self::METADATA_BAD;
492 }
493 if ( !isset( $meta['originalWidth'] ) ) {
494 // Old but compatible
495 return self::METADATA_COMPATIBLE;
496 }
497
498 return self::METADATA_GOOD;
499 }
500
501 protected function visibleMetadataFields() {
502 return [ 'objectname', 'imagedescription' ];
503 }
504
510 public function formatMetadata( $file, $context = false ) {
511 $result = [
512 'visible' => [],
513 'collapsed' => []
514 ];
515 $metadata = $this->validateMetadata( $file->getMetadataArray() );
516 if ( !$metadata || isset( $metadata['error'] ) ) {
517 return false;
518 }
519
520 /* @todo Add a formatter
521 $format = new FormatSVG( $metadata );
522 $formatted = $format->getFormattedData();
523 */
524
525 // Sort fields into visible and collapsed
526 $visibleFields = $this->visibleMetadataFields();
527
528 $showMeta = false;
529 foreach ( $metadata as $name => $value ) {
530 $tag = strtolower( $name );
531 if ( isset( self::$metaConversion[$tag] ) ) {
532 $tag = strtolower( self::$metaConversion[$tag] );
533 } else {
534 // Do not output other metadata not in list
535 continue;
536 }
537 $showMeta = true;
538 self::addMeta( $result,
539 in_array( $tag, $visibleFields ) ? 'visible' : 'collapsed',
540 'exif',
541 $tag,
542 $value
543 );
544 }
545
546 return $showMeta ? $result : false;
547 }
548
554 public function validateParam( $name, $value ) {
555 if ( in_array( $name, [ 'width', 'height' ] ) ) {
556 // Reject negative heights, widths
557 return ( $value > 0 );
558 }
559 if ( $name === 'lang' ) {
560 // Validate $code
561 if ( $value === ''
562 || !LanguageCode::isWellFormedLanguageTag( $value )
563 ) {
564 return false;
565 }
566
567 return true;
568 }
569
570 // Only lang, width and height are acceptable keys
571 return false;
572 }
573
578 public function makeParamString( $params ) {
579 $lang = '';
580 $code = $this->getLanguageFromParams( $params );
581 if ( $code !== self::SVG_DEFAULT_RENDER_LANG ) {
582 $lang = 'lang' . strtolower( $code ) . '-';
583 }
584 if ( !isset( $params['width'] ) ) {
585 return false;
586 }
587
588 return "$lang{$params['width']}px";
589 }
590
591 public function parseParamString( $str ) {
592 $m = false;
593 // Language codes are supposed to be lowercase
594 if ( preg_match( '/^lang([a-z]+(?:-[a-z]+)*)-(\d+)px$/', $str, $m ) ) {
595 if ( LanguageCode::isWellFormedLanguageTag( $m[1] ) ) {
596 return [ 'width' => array_pop( $m ), 'lang' => $m[1] ];
597 }
598 return [ 'width' => array_pop( $m ), 'lang' => self::SVG_DEFAULT_RENDER_LANG ];
599 }
600 if ( preg_match( '/^(\d+)px$/', $str, $m ) ) {
601 return [ 'width' => $m[1], 'lang' => self::SVG_DEFAULT_RENDER_LANG ];
602 }
603 return false;
604 }
605
606 public function getParamMap() {
607 return [ 'img_lang' => 'lang', 'img_width' => 'width' ];
608 }
609
614 protected function getScriptParams( $params ) {
615 $scriptParams = [ 'width' => $params['width'] ];
616 if ( isset( $params['lang'] ) ) {
617 $scriptParams['lang'] = $params['lang'];
618 }
619
620 return $scriptParams;
621 }
622
623 public function getCommonMetaArray( File $file ) {
624 $metadata = $this->validateMetadata( $file->getMetadataArray() );
625 if ( !$metadata || isset( $metadata['error'] ) ) {
626 return [];
627 }
628 $stdMetadata = [];
629 foreach ( $metadata as $name => $value ) {
630 $tag = strtolower( $name );
631 if ( $tag === 'originalwidth' || $tag === 'originalheight' ) {
632 // Skip these. In the exif metadata stuff, it is assumed these
633 // are measured in px, which is not the case here.
634 continue;
635 }
636 if ( isset( self::$metaConversion[$tag] ) ) {
637 $tag = self::$metaConversion[$tag];
638 $stdMetadata[$tag] = $value;
639 }
640 }
641
642 return $stdMetadata;
643 }
644}
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:73
getMetadataArray()
Get the unserialized handler-specific metadata STUB.
Definition File.php:757
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.
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.