Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
70.56% covered (warning)
70.56%
127 / 180
56.25% covered (warning)
56.25%
9 / 16
CRAP
0.00% covered (danger)
0.00%
0 / 1
ImageModule
70.56% covered (warning)
70.56%
127 / 180
56.25% covered (warning)
56.25%
9 / 16
172.85
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
2 / 2
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
100.00% covered (success)
100.00%
28 / 28
100.00% covered (success)
100.00%
1 / 1
3
 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%
4 / 4
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 * 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 */
21namespace MediaWiki\ResourceLoader;
22
23use InvalidArgumentException;
24use Wikimedia\Minify\CSSMin;
25
26/**
27 * Module for generated and embedded images.
28 *
29 * @ingroup ResourceLoader
30 * @since 1.25
31 */
32class 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}