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