ResourceLoaderImageModule.php 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499
  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. * @author Trevor Parscal
  20. */
  21. /**
  22. * Module for generated and embedded images.
  23. *
  24. * @ingroup ResourceLoader
  25. * @since 1.25
  26. */
  27. class ResourceLoaderImageModule extends ResourceLoaderModule {
  28. /** @var array|null */
  29. protected $definition = null;
  30. /**
  31. * Local base path, see __construct()
  32. * @var string
  33. */
  34. protected $localBasePath = '';
  35. protected $origin = self::ORIGIN_CORE_SITEWIDE;
  36. /** @var ResourceLoaderImage[][]|null */
  37. protected $imageObjects = null;
  38. /** @var array */
  39. protected $images = [];
  40. /** @var string|null */
  41. protected $defaultColor = null;
  42. protected $useDataURI = true;
  43. /** @var array|null */
  44. protected $globalVariants = null;
  45. /** @var array */
  46. protected $variants = [];
  47. /** @var string|null */
  48. protected $prefix = null;
  49. protected $selectorWithoutVariant = '.{prefix}-{name}';
  50. protected $selectorWithVariant = '.{prefix}-{name}-{variant}';
  51. protected $targets = [ 'desktop', 'mobile' ];
  52. /**
  53. * Constructs a new module from an options array.
  54. *
  55. * @param array $options List of options; if not given or empty, an empty module will be
  56. * constructed
  57. * @param string|null $localBasePath Base path to prepend to all local paths in $options. Defaults
  58. * to $IP
  59. *
  60. * Below is a description for the $options array:
  61. * @par Construction options:
  62. * @code
  63. * [
  64. * // Base path to prepend to all local paths in $options. Defaults to $IP
  65. * 'localBasePath' => [base path],
  66. * // Path to JSON file that contains any of the settings below
  67. * 'data' => [file path string]
  68. * // CSS class prefix to use in all style rules
  69. * 'prefix' => [CSS class prefix],
  70. * // Alternatively: Format of CSS selector to use in all style rules
  71. * 'selector' => [CSS selector template, variables: {prefix} {name} {variant}],
  72. * // Alternatively: When using variants
  73. * 'selectorWithoutVariant' => [CSS selector template, variables: {prefix} {name}],
  74. * 'selectorWithVariant' => [CSS selector template, variables: {prefix} {name} {variant}],
  75. * // List of variants that may be used for the image files
  76. * 'variants' => [
  77. * // This level of nesting can be omitted if you use the same images for every skin
  78. * [skin name (or 'default')] => [
  79. * [variant name] => [
  80. * 'color' => [color string, e.g. '#ffff00'],
  81. * 'global' => [boolean, if true, this variant is available
  82. * for all images of this type],
  83. * ],
  84. * ...
  85. * ],
  86. * ...
  87. * ],
  88. * // List of image files and their options
  89. * 'images' => [
  90. * // This level of nesting can be omitted if you use the same images for every skin
  91. * [skin name (or 'default')] => [
  92. * [icon name] => [
  93. * 'file' => [file path string or array whose values are file path strings
  94. * and whose keys are 'default', 'ltr', 'rtl', a single
  95. * language code like 'en', or a list of language codes like
  96. * 'en,de,ar'],
  97. * 'variants' => [array of variant name strings, variants
  98. * available for this image],
  99. * ],
  100. * ...
  101. * ],
  102. * ...
  103. * ],
  104. * ]
  105. * @endcode
  106. * @throws InvalidArgumentException
  107. */
  108. public function __construct( $options = [], $localBasePath = null ) {
  109. $this->localBasePath = static::extractLocalBasePath( $options, $localBasePath );
  110. $this->definition = $options;
  111. }
  112. /**
  113. * Parse definition and external JSON data, if referenced.
  114. */
  115. protected function loadFromDefinition() {
  116. if ( $this->definition === null ) {
  117. return;
  118. }
  119. $options = $this->definition;
  120. $this->definition = null;
  121. if ( isset( $options['data'] ) ) {
  122. $dataPath = $this->getLocalPath( $options['data'] );
  123. $data = json_decode( file_get_contents( $dataPath ), true );
  124. $options = array_merge( $data, $options );
  125. }
  126. // Accepted combinations:
  127. // * prefix
  128. // * selector
  129. // * selectorWithoutVariant + selectorWithVariant
  130. // * prefix + selector
  131. // * prefix + selectorWithoutVariant + selectorWithVariant
  132. $prefix = isset( $options['prefix'] ) && $options['prefix'];
  133. $selector = isset( $options['selector'] ) && $options['selector'];
  134. $selectorWithoutVariant = isset( $options['selectorWithoutVariant'] )
  135. && $options['selectorWithoutVariant'];
  136. $selectorWithVariant = isset( $options['selectorWithVariant'] )
  137. && $options['selectorWithVariant'];
  138. if ( $selectorWithoutVariant && !$selectorWithVariant ) {
  139. throw new InvalidArgumentException(
  140. "Given 'selectorWithoutVariant' but no 'selectorWithVariant'."
  141. );
  142. }
  143. if ( $selectorWithVariant && !$selectorWithoutVariant ) {
  144. throw new InvalidArgumentException(
  145. "Given 'selectorWithVariant' but no 'selectorWithoutVariant'."
  146. );
  147. }
  148. if ( $selector && $selectorWithVariant ) {
  149. throw new InvalidArgumentException(
  150. "Incompatible 'selector' and 'selectorWithVariant'+'selectorWithoutVariant' given."
  151. );
  152. }
  153. if ( !$prefix && !$selector && !$selectorWithVariant ) {
  154. throw new InvalidArgumentException(
  155. "None of 'prefix', 'selector' or 'selectorWithVariant'+'selectorWithoutVariant' given."
  156. );
  157. }
  158. foreach ( $options as $member => $option ) {
  159. switch ( $member ) {
  160. case 'images':
  161. case 'variants':
  162. if ( !is_array( $option ) ) {
  163. throw new InvalidArgumentException(
  164. "Invalid list error. '$option' given, array expected."
  165. );
  166. }
  167. if ( !isset( $option['default'] ) ) {
  168. // Backwards compatibility
  169. $option = [ 'default' => $option ];
  170. }
  171. foreach ( $option as $skin => $data ) {
  172. if ( !is_array( $data ) ) {
  173. throw new InvalidArgumentException(
  174. "Invalid list error. '$data' given, array expected."
  175. );
  176. }
  177. }
  178. $this->{$member} = $option;
  179. break;
  180. case 'useDataURI':
  181. $this->{$member} = (bool)$option;
  182. break;
  183. case 'defaultColor':
  184. case 'prefix':
  185. case 'selectorWithoutVariant':
  186. case 'selectorWithVariant':
  187. $this->{$member} = (string)$option;
  188. break;
  189. case 'selector':
  190. $this->selectorWithoutVariant = $this->selectorWithVariant = (string)$option;
  191. }
  192. }
  193. }
  194. /**
  195. * Get CSS class prefix used by this module.
  196. * @return string
  197. */
  198. public function getPrefix() {
  199. $this->loadFromDefinition();
  200. return $this->prefix;
  201. }
  202. /**
  203. * Get CSS selector templates used by this module.
  204. * @return string[]
  205. */
  206. public function getSelectors() {
  207. $this->loadFromDefinition();
  208. return [
  209. 'selectorWithoutVariant' => $this->selectorWithoutVariant,
  210. 'selectorWithVariant' => $this->selectorWithVariant,
  211. ];
  212. }
  213. /**
  214. * Get a ResourceLoaderImage object for given image.
  215. * @param string $name Image name
  216. * @param ResourceLoaderContext $context
  217. * @return ResourceLoaderImage|null
  218. */
  219. public function getImage( $name, ResourceLoaderContext $context ) {
  220. $this->loadFromDefinition();
  221. $images = $this->getImages( $context );
  222. return $images[$name] ?? null;
  223. }
  224. /**
  225. * Get ResourceLoaderImage objects for all images.
  226. * @param ResourceLoaderContext $context
  227. * @return ResourceLoaderImage[] Array keyed by image name
  228. */
  229. public function getImages( ResourceLoaderContext $context ) {
  230. $skin = $context->getSkin();
  231. if ( $this->imageObjects === null ) {
  232. $this->loadFromDefinition();
  233. $this->imageObjects = [];
  234. }
  235. if ( !isset( $this->imageObjects[$skin] ) ) {
  236. $this->imageObjects[$skin] = [];
  237. if ( !isset( $this->images[$skin] ) ) {
  238. $this->images[$skin] = $this->images['default'] ?? [];
  239. }
  240. foreach ( $this->images[$skin] as $name => $options ) {
  241. $fileDescriptor = is_array( $options ) ? $options['file'] : $options;
  242. $allowedVariants = array_merge(
  243. ( is_array( $options ) && isset( $options['variants'] ) ) ? $options['variants'] : [],
  244. $this->getGlobalVariants( $context )
  245. );
  246. if ( isset( $this->variants[$skin] ) ) {
  247. $variantConfig = array_intersect_key(
  248. $this->variants[$skin],
  249. array_fill_keys( $allowedVariants, true )
  250. );
  251. } else {
  252. $variantConfig = [];
  253. }
  254. $image = new ResourceLoaderImage(
  255. $name,
  256. $this->getName(),
  257. $fileDescriptor,
  258. $this->localBasePath,
  259. $variantConfig,
  260. $this->defaultColor
  261. );
  262. $this->imageObjects[$skin][$image->getName()] = $image;
  263. }
  264. }
  265. return $this->imageObjects[$skin];
  266. }
  267. /**
  268. * Get list of variants in this module that are 'global', i.e., available
  269. * for every image regardless of image options.
  270. * @param ResourceLoaderContext $context
  271. * @return string[]
  272. */
  273. public function getGlobalVariants( ResourceLoaderContext $context ) {
  274. $skin = $context->getSkin();
  275. if ( $this->globalVariants === null ) {
  276. $this->loadFromDefinition();
  277. $this->globalVariants = [];
  278. }
  279. if ( !isset( $this->globalVariants[$skin] ) ) {
  280. $this->globalVariants[$skin] = [];
  281. if ( !isset( $this->variants[$skin] ) ) {
  282. $this->variants[$skin] = $this->variants['default'] ?? [];
  283. }
  284. foreach ( $this->variants[$skin] as $name => $config ) {
  285. if ( isset( $config['global'] ) && $config['global'] ) {
  286. $this->globalVariants[$skin][] = $name;
  287. }
  288. }
  289. }
  290. return $this->globalVariants[$skin];
  291. }
  292. /**
  293. * @param ResourceLoaderContext $context
  294. * @return array
  295. */
  296. public function getStyles( ResourceLoaderContext $context ) {
  297. $this->loadFromDefinition();
  298. // Build CSS rules
  299. $rules = [];
  300. $script = $context->getResourceLoader()->getLoadScript( $this->getSource() );
  301. $selectors = $this->getSelectors();
  302. foreach ( $this->getImages( $context ) as $name => $image ) {
  303. $declarations = $this->getStyleDeclarations( $context, $image, $script );
  304. $selector = strtr(
  305. $selectors['selectorWithoutVariant'],
  306. [
  307. '{prefix}' => $this->getPrefix(),
  308. '{name}' => $name,
  309. '{variant}' => '',
  310. ]
  311. );
  312. $rules[] = "$selector {\n\t$declarations\n}";
  313. foreach ( $image->getVariants() as $variant ) {
  314. $declarations = $this->getStyleDeclarations( $context, $image, $script, $variant );
  315. $selector = strtr(
  316. $selectors['selectorWithVariant'],
  317. [
  318. '{prefix}' => $this->getPrefix(),
  319. '{name}' => $name,
  320. '{variant}' => $variant,
  321. ]
  322. );
  323. $rules[] = "$selector {\n\t$declarations\n}";
  324. }
  325. }
  326. $style = implode( "\n", $rules );
  327. return [ 'all' => $style ];
  328. }
  329. /**
  330. * This method must not be used by getDefinitionSummary as doing so would cause
  331. * an infinite loop (we use ResourceLoaderImage::getUrl below which calls
  332. * Module:getVersionHash, which calls Module::getDefinitionSummary).
  333. *
  334. * @param ResourceLoaderContext $context
  335. * @param ResourceLoaderImage $image Image to get the style for
  336. * @param string $script URL to load.php
  337. * @param string|null $variant Variant to get the style for
  338. * @return string
  339. */
  340. private function getStyleDeclarations(
  341. ResourceLoaderContext $context,
  342. ResourceLoaderImage $image,
  343. $script,
  344. $variant = null
  345. ) {
  346. $imageDataUri = $this->useDataURI ? $image->getDataUri( $context, $variant, 'original' ) : false;
  347. $primaryUrl = $imageDataUri ?: $image->getUrl( $context, $script, $variant, 'original' );
  348. $declarations = $this->getCssDeclarations(
  349. $primaryUrl,
  350. $image->getUrl( $context, $script, $variant, 'rasterized' )
  351. );
  352. return implode( "\n\t", $declarations );
  353. }
  354. /**
  355. * SVG support using a transparent gradient to guarantee cross-browser
  356. * compatibility (browsers able to understand gradient syntax support also SVG).
  357. * http://pauginer.tumblr.com/post/36614680636/invisible-gradient-technique
  358. *
  359. * Keep synchronized with the .background-image-svg LESS mixin in
  360. * /resources/src/mediawiki.less/mediawiki.mixins.less.
  361. *
  362. * @param string $primary Primary URI
  363. * @param string $fallback Fallback URI
  364. * @return string[] CSS declarations to use given URIs as background-image
  365. */
  366. protected function getCssDeclarations( $primary, $fallback ) {
  367. $primaryUrl = CSSMin::buildUrlValue( $primary );
  368. $fallbackUrl = CSSMin::buildUrlValue( $fallback );
  369. return [
  370. "background-image: $fallbackUrl;",
  371. "background-image: linear-gradient(transparent, transparent), $primaryUrl;",
  372. ];
  373. }
  374. /**
  375. * @return bool
  376. */
  377. public function supportsURLLoading() {
  378. return false;
  379. }
  380. /**
  381. * Get the definition summary for this module.
  382. *
  383. * @param ResourceLoaderContext $context
  384. * @return array
  385. */
  386. public function getDefinitionSummary( ResourceLoaderContext $context ) {
  387. $this->loadFromDefinition();
  388. $summary = parent::getDefinitionSummary( $context );
  389. $options = [];
  390. foreach ( [
  391. 'localBasePath',
  392. 'images',
  393. 'variants',
  394. 'prefix',
  395. 'selectorWithoutVariant',
  396. 'selectorWithVariant',
  397. ] as $member ) {
  398. $options[$member] = $this->{$member};
  399. }
  400. $summary[] = [
  401. 'options' => $options,
  402. 'fileHashes' => $this->getFileHashes( $context ),
  403. ];
  404. return $summary;
  405. }
  406. /**
  407. * Helper method for getDefinitionSummary.
  408. * @param ResourceLoaderContext $context
  409. * @return array
  410. */
  411. private function getFileHashes( ResourceLoaderContext $context ) {
  412. $this->loadFromDefinition();
  413. $files = [];
  414. foreach ( $this->getImages( $context ) as $name => $image ) {
  415. $files[] = $image->getPath( $context );
  416. }
  417. $files = array_values( array_unique( $files ) );
  418. return array_map( [ __CLASS__, 'safeFileHash' ], $files );
  419. }
  420. /**
  421. * @param string|ResourceLoaderFilePath $path
  422. * @return string
  423. */
  424. protected function getLocalPath( $path ) {
  425. if ( $path instanceof ResourceLoaderFilePath ) {
  426. return $path->getLocalPath();
  427. }
  428. return "{$this->localBasePath}/$path";
  429. }
  430. /**
  431. * Extract a local base path from module definition information.
  432. *
  433. * @param array $options Module definition
  434. * @param string|null $localBasePath Path to use if not provided in module definition. Defaults
  435. * to $IP
  436. * @return string Local base path
  437. */
  438. public static function extractLocalBasePath( $options, $localBasePath = null ) {
  439. global $IP;
  440. if ( $localBasePath === null ) {
  441. $localBasePath = $IP;
  442. }
  443. if ( array_key_exists( 'localBasePath', $options ) ) {
  444. $localBasePath = (string)$options['localBasePath'];
  445. }
  446. return $localBasePath;
  447. }
  448. /**
  449. * @return string
  450. */
  451. public function getType() {
  452. return self::LOAD_STYLES;
  453. }
  454. }