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