ResourceLoaderImage.php 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480
  1. <?php
  2. /**
  3. * This program is free software; you can redistribute it and/or modify
  4. * it under the terms of the GNU General Public License as published by
  5. * the Free Software Foundation; either version 2 of the License, or
  6. * (at your option) any later version.
  7. *
  8. * This program is distributed in the hope that it will be useful,
  9. * but WITHOUT ANY WARRANTY; without even the implied warranty of
  10. * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  11. * GNU General Public License for more details.
  12. *
  13. * You should have received a copy of the GNU General Public License along
  14. * with this program; if not, write to the Free Software Foundation, Inc.,
  15. * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
  16. * http://www.gnu.org/copyleft/gpl.html
  17. *
  18. * @file
  19. */
  20. use MediaWiki\Shell\Shell;
  21. /**
  22. * Class encapsulating an image used in a ResourceLoaderImageModule.
  23. *
  24. * @ingroup ResourceLoader
  25. * @since 1.25
  26. */
  27. class ResourceLoaderImage {
  28. /**
  29. * Map of allowed file extensions to their MIME types.
  30. * @var array
  31. */
  32. protected static $fileTypes = [
  33. 'svg' => 'image/svg+xml',
  34. 'png' => 'image/png',
  35. 'gif' => 'image/gif',
  36. 'jpg' => 'image/jpg',
  37. ];
  38. /** @var string */
  39. private $name;
  40. /** @var string */
  41. private $module;
  42. /** @var string|array */
  43. private $descriptor;
  44. /** @var string */
  45. private $basePath;
  46. /** @var array */
  47. private $variants;
  48. /** @var string|null */
  49. private $defaultColor;
  50. /** @var string */
  51. private $extension;
  52. /**
  53. * @param string $name Self-name of the image as known to ResourceLoaderImageModule.
  54. * @param string $module Self-name of the module containing this image.
  55. * Used to find the image in the registry e.g. through a load.php url.
  56. * @param string|array $descriptor Path to image file, or array structure containing paths
  57. * @param string $basePath Directory to which paths in descriptor refer
  58. * @param array $variants
  59. * @param string|null $defaultColor of the base variant
  60. * @throws InvalidArgumentException
  61. */
  62. public function __construct( $name, $module, $descriptor, $basePath, $variants,
  63. $defaultColor = null
  64. ) {
  65. $this->name = $name;
  66. $this->module = $module;
  67. $this->descriptor = $descriptor;
  68. $this->basePath = $basePath;
  69. $this->variants = $variants;
  70. $this->defaultColor = $defaultColor;
  71. // Expand shorthands:
  72. // [ "en,de,fr" => "foo.svg" ]
  73. // → [ "en" => "foo.svg", "de" => "foo.svg", "fr" => "foo.svg" ]
  74. if ( is_array( $this->descriptor ) && isset( $this->descriptor['lang'] ) ) {
  75. foreach ( array_keys( $this->descriptor['lang'] ) as $langList ) {
  76. if ( strpos( $langList, ',' ) !== false ) {
  77. $this->descriptor['lang'] += array_fill_keys(
  78. explode( ',', $langList ),
  79. $this->descriptor['lang'][$langList]
  80. );
  81. unset( $this->descriptor['lang'][$langList] );
  82. }
  83. }
  84. }
  85. // Remove 'deprecated' key
  86. if ( is_array( $this->descriptor ) ) {
  87. unset( $this->descriptor['deprecated'] );
  88. }
  89. // Ensure that all files have common extension.
  90. $extensions = [];
  91. $descriptor = is_array( $this->descriptor ) ? $this->descriptor : [ $this->descriptor ];
  92. array_walk_recursive( $descriptor, function ( $path ) use ( &$extensions ) {
  93. $extensions[] = pathinfo( $this->getLocalPath( $path ), PATHINFO_EXTENSION );
  94. } );
  95. $extensions = array_unique( $extensions );
  96. if ( count( $extensions ) !== 1 ) {
  97. throw new InvalidArgumentException(
  98. "File type for different image files of '$name' not the same in module '$module'"
  99. );
  100. }
  101. $ext = $extensions[0];
  102. if ( !isset( self::$fileTypes[$ext] ) ) {
  103. throw new InvalidArgumentException(
  104. "Invalid file type for image files of '$name' (valid: svg, png, gif, jpg) in module '$module'"
  105. );
  106. }
  107. $this->extension = $ext;
  108. }
  109. /**
  110. * Get name of this image.
  111. *
  112. * @return string
  113. */
  114. public function getName() {
  115. return $this->name;
  116. }
  117. /**
  118. * Get name of the module this image belongs to.
  119. *
  120. * @return string
  121. */
  122. public function getModule() {
  123. return $this->module;
  124. }
  125. /**
  126. * Get the list of variants this image can be converted to.
  127. *
  128. * @return string[]
  129. */
  130. public function getVariants() {
  131. return array_keys( $this->variants );
  132. }
  133. /**
  134. * Get the path to image file for given context.
  135. *
  136. * @param ResourceLoaderContext $context Any context
  137. * @return string
  138. * @throws MWException If no matching path is found
  139. */
  140. public function getPath( ResourceLoaderContext $context ) {
  141. $desc = $this->descriptor;
  142. if ( !is_array( $desc ) ) {
  143. return $this->getLocalPath( $desc );
  144. }
  145. if ( isset( $desc['lang'] ) ) {
  146. $contextLang = $context->getLanguage();
  147. if ( isset( $desc['lang'][$contextLang] ) ) {
  148. return $this->getLocalPath( $desc['lang'][$contextLang] );
  149. }
  150. $fallbacks = Language::getFallbacksFor( $contextLang, Language::STRICT_FALLBACKS );
  151. foreach ( $fallbacks as $lang ) {
  152. if ( isset( $desc['lang'][$lang] ) ) {
  153. return $this->getLocalPath( $desc['lang'][$lang] );
  154. }
  155. }
  156. }
  157. if ( isset( $desc[$context->getDirection()] ) ) {
  158. return $this->getLocalPath( $desc[$context->getDirection()] );
  159. }
  160. if ( isset( $desc['default'] ) ) {
  161. return $this->getLocalPath( $desc['default'] );
  162. } else {
  163. throw new MWException( 'No matching path found' );
  164. }
  165. }
  166. /**
  167. * @param string|ResourceLoaderFilePath $path
  168. * @return string
  169. */
  170. protected function getLocalPath( $path ) {
  171. if ( $path instanceof ResourceLoaderFilePath ) {
  172. return $path->getLocalPath();
  173. }
  174. return "{$this->basePath}/$path";
  175. }
  176. /**
  177. * Get the extension of the image.
  178. *
  179. * @param string $format Format to get the extension for, 'original' or 'rasterized'
  180. * @return string Extension without leading dot, e.g. 'png'
  181. */
  182. public function getExtension( $format = 'original' ) {
  183. if ( $format === 'rasterized' && $this->extension === 'svg' ) {
  184. return 'png';
  185. }
  186. return $this->extension;
  187. }
  188. /**
  189. * Get the MIME type of the image.
  190. *
  191. * @param string $format Format to get the MIME type for, 'original' or 'rasterized'
  192. * @return string
  193. */
  194. public function getMimeType( $format = 'original' ) {
  195. $ext = $this->getExtension( $format );
  196. return self::$fileTypes[$ext];
  197. }
  198. /**
  199. * Get the load.php URL that will produce this image.
  200. *
  201. * @param ResourceLoaderContext $context Any context
  202. * @param string $script URL to load.php
  203. * @param string|null $variant Variant to get the URL for
  204. * @param string $format Format to get the URL for, 'original' or 'rasterized'
  205. * @return string URL
  206. */
  207. public function getUrl( ResourceLoaderContext $context, $script, $variant, $format ) {
  208. $query = [
  209. 'modules' => $this->getModule(),
  210. 'image' => $this->getName(),
  211. 'variant' => $variant,
  212. 'format' => $format,
  213. ];
  214. if ( $this->varyOnLanguage() ) {
  215. $query['lang'] = $context->getLanguage();
  216. }
  217. // The following parameters are at the end to keep the original order of the parameters.
  218. $query['skin'] = $context->getSkin();
  219. $rl = $context->getResourceLoader();
  220. $query['version'] = $rl->makeVersionQuery( $context, [ $this->getModule() ] );
  221. return wfAppendQuery( $script, $query );
  222. }
  223. /**
  224. * Get the data: URI that will produce this image.
  225. *
  226. * @param ResourceLoaderContext $context Any context
  227. * @param string|null $variant Variant to get the URI for
  228. * @param string $format Format to get the URI for, 'original' or 'rasterized'
  229. * @return string
  230. */
  231. public function getDataUri( ResourceLoaderContext $context, $variant, $format ) {
  232. $type = $this->getMimeType( $format );
  233. $contents = $this->getImageData( $context, $variant, $format );
  234. return CSSMin::encodeStringAsDataURI( $contents, $type );
  235. }
  236. /**
  237. * Get actual image data for this image. This can be saved to a file or sent to the browser to
  238. * produce the converted image.
  239. *
  240. * Call getExtension() or getMimeType() with the same $format argument to learn what file type the
  241. * returned data uses.
  242. *
  243. * @param ResourceLoaderContext $context Image context, or any context if $variant and $format
  244. * given.
  245. * @param string|null $variant Variant to get the data for. Optional; if given, overrides the data
  246. * from $context.
  247. * @param string $format Format to get the data for, 'original' or 'rasterized'. Optional; if
  248. * given, overrides the data from $context.
  249. * @return string|false Possibly binary image data, or false on failure
  250. * @throws MWException If the image file doesn't exist
  251. */
  252. public function getImageData( ResourceLoaderContext $context, $variant = false, $format = false ) {
  253. if ( $variant === false ) {
  254. $variant = $context->getVariant();
  255. }
  256. if ( $format === false ) {
  257. $format = $context->getFormat();
  258. }
  259. $path = $this->getPath( $context );
  260. if ( !file_exists( $path ) ) {
  261. throw new MWException( "File '$path' does not exist" );
  262. }
  263. if ( $this->getExtension() !== 'svg' ) {
  264. return file_get_contents( $path );
  265. }
  266. if ( $variant && isset( $this->variants[$variant] ) ) {
  267. $data = $this->variantize( $this->variants[$variant], $context );
  268. } else {
  269. $defaultColor = $this->defaultColor;
  270. $data = $defaultColor ?
  271. $this->variantize( [ 'color' => $defaultColor ], $context ) :
  272. file_get_contents( $path );
  273. }
  274. if ( $format === 'rasterized' ) {
  275. $data = $this->rasterize( $data );
  276. if ( !$data ) {
  277. wfDebugLog( 'ResourceLoaderImage', __METHOD__ . " failed to rasterize for $path" );
  278. }
  279. }
  280. return $data;
  281. }
  282. /**
  283. * Send response headers (using the header() function) that are necessary to correctly serve the
  284. * image data for this image, as returned by getImageData().
  285. *
  286. * Note that the headers are independent of the language or image variant.
  287. *
  288. * @param ResourceLoaderContext $context Image context
  289. */
  290. public function sendResponseHeaders( ResourceLoaderContext $context ) {
  291. $format = $context->getFormat();
  292. $mime = $this->getMimeType( $format );
  293. $filename = $this->getName() . '.' . $this->getExtension( $format );
  294. header( 'Content-Type: ' . $mime );
  295. header( 'Content-Disposition: ' .
  296. FileBackend::makeContentDisposition( 'inline', $filename ) );
  297. }
  298. /**
  299. * Convert this image, which is assumed to be SVG, to given variant.
  300. *
  301. * @param array $variantConf Array with a 'color' key, its value will be used as fill color
  302. * @param ResourceLoaderContext $context Image context
  303. * @return string New SVG file data
  304. */
  305. protected function variantize( $variantConf, ResourceLoaderContext $context ) {
  306. $dom = new DOMDocument;
  307. $dom->loadXML( file_get_contents( $this->getPath( $context ) ) );
  308. $root = $dom->documentElement;
  309. $titleNode = null;
  310. $wrapper = $dom->createElementNS( 'http://www.w3.org/2000/svg', 'g' );
  311. // Reattach all direct children of the `<svg>` root node to the `<g>` wrapper
  312. while ( $root->firstChild ) {
  313. $node = $root->firstChild;
  314. // @phan-suppress-next-line PhanUndeclaredProperty False positive
  315. if ( !$titleNode && $node->nodeType === XML_ELEMENT_NODE && $node->tagName === 'title' ) {
  316. // Remember the first encountered `<title>` node
  317. $titleNode = $node;
  318. }
  319. $wrapper->appendChild( $node );
  320. }
  321. if ( $titleNode ) {
  322. // Reattach the `<title>` node to the `<svg>` root node rather than the `<g>` wrapper
  323. $root->appendChild( $titleNode );
  324. }
  325. $root->appendChild( $wrapper );
  326. $wrapper->setAttribute( 'fill', $variantConf['color'] );
  327. return $dom->saveXML();
  328. }
  329. /**
  330. * Massage the SVG image data for converters which don't understand some path data syntax.
  331. *
  332. * This is necessary for rsvg and ImageMagick when compiled with rsvg support.
  333. * Upstream bug is https://bugzilla.gnome.org/show_bug.cgi?id=620923, fixed 2014-11-10, so
  334. * this will be needed for a while. (T76852)
  335. *
  336. * @param string $svg SVG image data
  337. * @return string Massaged SVG image data
  338. */
  339. protected function massageSvgPathdata( $svg ) {
  340. $dom = new DOMDocument;
  341. $dom->loadXML( $svg );
  342. foreach ( $dom->getElementsByTagName( 'path' ) as $node ) {
  343. $pathData = $node->getAttribute( 'd' );
  344. // Make sure there is at least one space between numbers, and that leading zero is not omitted.
  345. // rsvg has issues with syntax like "M-1-2" and "M.445.483" and especially "M-.445-.483".
  346. $pathData = preg_replace( '/(-?)(\d*\.\d+|\d+)/', ' ${1}0$2 ', $pathData );
  347. // Strip unnecessary leading zeroes for prettiness, not strictly necessary
  348. $pathData = preg_replace( '/([ -])0(\d)/', '$1$2', $pathData );
  349. $node->setAttribute( 'd', $pathData );
  350. }
  351. return $dom->saveXML();
  352. }
  353. /**
  354. * Convert passed image data, which is assumed to be SVG, to PNG.
  355. *
  356. * @param string $svg SVG image data
  357. * @return string|bool PNG image data, or false on failure
  358. */
  359. protected function rasterize( $svg ) {
  360. /**
  361. * This code should be factored out to a separate method on SvgHandler, or perhaps a separate
  362. * class, with a separate set of configuration settings.
  363. *
  364. * This is a distinct use case from regular SVG rasterization:
  365. * * We can skip many sanity and security checks (as the images come from a trusted source,
  366. * rather than from the user).
  367. * * We need to provide extra options to some converters to achieve acceptable quality for very
  368. * small images, which might cause performance issues in the general case.
  369. * * We want to directly pass image data to the converter, rather than a file path.
  370. *
  371. * See https://phabricator.wikimedia.org/T76473#801446 for examples of what happens with the
  372. * default settings.
  373. *
  374. * For now, we special-case rsvg (used in WMF production) and do a messy workaround for other
  375. * converters.
  376. */
  377. global $wgSVGConverter, $wgSVGConverterPath;
  378. $svg = $this->massageSvgPathdata( $svg );
  379. // Sometimes this might be 'rsvg-secure'. Long as it's rsvg.
  380. if ( strpos( $wgSVGConverter, 'rsvg' ) === 0 ) {
  381. $command = 'rsvg-convert';
  382. if ( $wgSVGConverterPath ) {
  383. $command = Shell::escape( "$wgSVGConverterPath/" ) . $command;
  384. }
  385. $process = proc_open(
  386. $command,
  387. [ 0 => [ 'pipe', 'r' ], 1 => [ 'pipe', 'w' ] ],
  388. $pipes
  389. );
  390. if ( is_resource( $process ) ) {
  391. fwrite( $pipes[0], $svg );
  392. fclose( $pipes[0] );
  393. $png = stream_get_contents( $pipes[1] );
  394. fclose( $pipes[1] );
  395. proc_close( $process );
  396. return $png ?: false;
  397. }
  398. return false;
  399. } else {
  400. // Write input to and read output from a temporary file
  401. $tempFilenameSvg = tempnam( wfTempDir(), 'ResourceLoaderImage' );
  402. $tempFilenamePng = tempnam( wfTempDir(), 'ResourceLoaderImage' );
  403. file_put_contents( $tempFilenameSvg, $svg );
  404. $svgReader = new SVGReader( $tempFilenameSvg );
  405. $metadata = $svgReader->getMetadata();
  406. if ( !isset( $metadata['width'] ) || !isset( $metadata['height'] ) ) {
  407. unlink( $tempFilenameSvg );
  408. return false;
  409. }
  410. $handler = new SvgHandler;
  411. $res = $handler->rasterize(
  412. $tempFilenameSvg,
  413. $tempFilenamePng,
  414. $metadata['width'],
  415. $metadata['height']
  416. );
  417. unlink( $tempFilenameSvg );
  418. $png = null;
  419. if ( $res === true ) {
  420. $png = file_get_contents( $tempFilenamePng );
  421. unlink( $tempFilenamePng );
  422. }
  423. return $png ?: false;
  424. }
  425. }
  426. /**
  427. * Check if the image depends on the language.
  428. *
  429. * @return bool
  430. */
  431. private function varyOnLanguage() {
  432. return is_array( $this->descriptor ) && (
  433. isset( $this->descriptor['ltr'] ) ||
  434. isset( $this->descriptor['rtl'] ) ||
  435. isset( $this->descriptor['lang'] ) );
  436. }
  437. }