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