Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
93.68% covered (success)
93.68%
89 / 95
70.00% covered (warning)
70.00%
7 / 10
CRAP
0.00% covered (danger)
0.00%
0 / 1
LazyImageTransform
93.68% covered (success)
93.68%
89 / 95
70.00% covered (warning)
70.00%
7 / 10
37.34
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 apply
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 getImageDimension
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
4
 getImageDimensions
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 isDimensionSmallerThanThreshold
90.91% covered (success)
90.91%
10 / 11
0.00% covered (danger)
0.00%
0 / 1
5.02
 skipLazyLoadingForSmallDimensions
42.86% covered (danger)
42.86%
3 / 7
0.00% covered (danger)
0.00%
0 / 1
9.66
 doRewriteImagesForLazyLoading
97.78% covered (success)
97.78%
44 / 45
0.00% covered (danger)
0.00%
0 / 1
11
 copyStyles
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 copyClasses
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 gradeCImageSupport
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2
3namespace MobileFrontend\Transforms;
4
5use MobileFrontend\Transforms\Utils\HtmlClassUtils;
6use MobileFrontend\Transforms\Utils\HtmlStyleUtils;
7use Wikimedia\Parsoid\DOM\Document;
8use Wikimedia\Parsoid\DOM\Element;
9use Wikimedia\Parsoid\Utils\DOMCompat;
10
11class LazyImageTransform implements IMobileTransform {
12
13    /**
14     * Do not lazy load images smaller than this size (in pixels)
15     */
16    private const SMALL_IMAGE_DIMENSION_THRESHOLD_IN_PX = 50;
17
18    /**
19     * Do not lazy load images smaller than this size (in relative to x-height of the current font)
20     */
21    private const SMALL_IMAGE_DIMENSION_THRESHOLD_IN_EX = 10;
22
23    /**
24     * @param bool $skipSmall whether small images should be excluded from lazy loading
25     */
26    public function __construct(
27        protected readonly bool $skipSmall = false,
28    ) {
29    }
30
31    /**
32     * Insert a image placeholder into the element
33     * which will be lazy loaded via JS.
34     * Assumes Legacy parser, not compatible with Parsoid.
35     *
36     * @param Element $node to be transformed
37     */
38    public function apply( Element $node ) {
39        $sections = DOMCompat::querySelectorAll( $node, 'section' );
40        foreach ( $sections as $sectionNumber => $section ) {
41            if ( $sectionNumber > 0 ) {
42                $this->doRewriteImagesForLazyLoading( $section, $section->ownerDocument );
43            }
44        }
45    }
46
47    /**
48     * @see MobileFormatter#getImageDimensions
49     *
50     * @param Element $img
51     * @param string $dimension Either "width" or "height"
52     * @return string|null
53     */
54    private function getImageDimension( Element $img, $dimension ) {
55        $style = DOMCompat::getAttribute( $img, 'style' ) ?? '';
56        $numMatches = preg_match( "/.*?{$dimension} *\: *([^;]*)/", $style, $matches );
57        $dimValue = DOMCompat::getAttribute( $img, $dimension );
58
59        if ( !$numMatches && $dimValue === null ) {
60            return null;
61        }
62
63        return $numMatches
64            ? trim( $matches[1] )
65            : $dimValue . 'px';
66    }
67
68    /**
69     * Determine the user perceived width and height of an image element based on `style`, `width`,
70     * and `height` attributes.
71     *
72     * As in the browser, the `style` attribute takes precedence over the `width` and `height`
73     * attributes. If the image has no `style`, `width` or `height` attributes, then the image is
74     * dimensionless.
75     *
76     * @param Element $img <img> element
77     * @return array with width and height parameters if dimensions are found
78     */
79    public function getImageDimensions( Element $img ) {
80        $result = [];
81
82        foreach ( [ 'width', 'height' ] as $dimensionName ) {
83            $dimension = $this->getImageDimension( $img, $dimensionName );
84
85            if ( $dimension ) {
86                $result[$dimensionName] = $dimension;
87            }
88        }
89
90        return $result;
91    }
92
93    /**
94     * Is image dimension small enough to not lazy load it
95     *
96     * @param string $dimension in css format, supports only px|ex units
97     * @return bool
98     */
99    public function isDimensionSmallerThanThreshold( $dimension ) {
100        $matches = null;
101        if ( preg_match( '/(\d+)(\.\d+)?(px|ex)/', $dimension, $matches ) === 0 ) {
102            return false;
103        }
104
105        $size = $matches[1];
106        $unit = array_pop( $matches );
107
108        switch ( strtolower( $unit ) ) {
109            case 'px':
110                return $size <= self::SMALL_IMAGE_DIMENSION_THRESHOLD_IN_PX;
111            case 'ex':
112                return $size <= self::SMALL_IMAGE_DIMENSION_THRESHOLD_IN_EX;
113            default:
114                return false;
115        }
116    }
117
118    /**
119     * @param array $dimensions
120     * @return bool
121     */
122    private function skipLazyLoadingForSmallDimensions( array $dimensions ) {
123        if ( array_key_exists( 'width', $dimensions )
124            && $this->isDimensionSmallerThanThreshold( $dimensions['width'] )
125        ) {
126            return true;
127        }
128        if ( array_key_exists( 'height', $dimensions )
129            && $this->isDimensionSmallerThanThreshold( $dimensions['height'] )
130        ) {
131            return true;
132        }
133        return false;
134    }
135
136    /**
137     * Enables images to be loaded asynchronously
138     *
139     * @param Element|Document $el Element or document to rewrite images in.
140     * @param ?Document $doc Document to create elements in
141     */
142    private function doRewriteImagesForLazyLoading( $el, ?Document $doc ) {
143        if ( $doc === null ) {
144            return;
145        }
146        $lazyLoadSkipSmallImages = $this->skipSmall;
147
148        foreach ( DOMCompat::querySelectorAll( $el, 'img' ) as $img ) {
149            $parent = $img->parentNode;
150            $dimensions = $this->getImageDimensions( $img );
151            $hasCompleteDimensions = isset( $dimensions['width'] ) && isset( $dimensions['height'] );
152
153            if ( $lazyLoadSkipSmallImages
154                && $this->skipLazyLoadingForSmallDimensions( $dimensions )
155            ) {
156                continue;
157            }
158            // T133085 - don't transform if we have no idea about dimensions of image
159            if ( !$hasCompleteDimensions ) {
160                continue;
161            }
162
163            // HTML only clients
164            $noscript = $doc->createElement( 'noscript' );
165
166            // To be loaded image placeholder
167            $imgPlaceholder = $doc->createElement( 'span' );
168            $this->copyClasses(
169                $img,
170                $imgPlaceholder,
171                [
172                    // T199351
173                    'thumbborder'
174                ],
175                [
176                    'lazy-image-placeholder',
177                ]
178            );
179
180            $this->copyStyles(
181                $img,
182                $imgPlaceholder,
183                [
184                    // T207929
185                    'vertical-align'
186                ],
187                [
188                    'width' => $dimensions['width'],
189                    'height' => $dimensions['height'],
190                ]
191            );
192            foreach ( [ 'src', 'alt', 'width', 'height', 'srcset', 'class', 'usemap' ] as $attr ) {
193                $attrValue = DOMCompat::getAttribute( $img, $attr );
194                if ( $attrValue !== null ) {
195                    $prefix = ( $attr === 'src' || $attr === 'srcset' ) ? 'data-mw-' : 'data-';
196                    $imgPlaceholder->setAttribute( $prefix . $attr, $attrValue );
197                }
198            }
199            // Assume data saving and remove srcset attribute from the non-js experience
200            $img->removeAttribute( 'srcset' );
201
202            // T145222: Add a non-breaking space inside placeholders to ensure that they do not report
203            // themselves as invisible when inline.
204            $imgPlaceholder->appendChild( $doc->createTextNode( "\u{00A0}" ) );
205
206            // Set the placeholder where the original image was
207            $parent->replaceChild( $imgPlaceholder, $img );
208            // Add the original image to the HTML only markup
209            // Document treats <noscript> as a scripting context, so we can't append Elements.
210            DOMCompat::setInnerHTML( $noscript, DOMCompat::getOuterHTML( $img ) );
211            // Insert the HTML only markup before the placeholder
212            $parent->insertBefore( $noscript, $imgPlaceholder );
213        }
214    }
215
216    /**
217     * Copy allowed styles from one HTMLElement to another, filtering them and
218     * unconditionally adding several in front of list
219     *
220     * @param Element|Document $from html element styles to be copied from
221     * @param Element|Document $to html element styles to be copied to
222     * @param string[] $enabled list of enabled styles to be copied
223     * @param array $additional key-value array of styles to be added/overriden unconditionally
224     *   at the beginning of string
225     */
226    private function copyStyles( $from, $to, array $enabled, array $additional ) {
227        $styles = HtmlStyleUtils::parseStyleString(
228            $from->hasAttribute( 'style' ) ? $from->getAttribute( 'style' ) : ''
229        );
230
231        $filteredStyles = HtmlStyleUtils::filterAllowedStyles( $styles, $enabled, $additional );
232        $to->setAttribute( 'style', HtmlStyleUtils::formStyleString( $filteredStyles ) );
233    }
234
235    /**
236     * Copy allowed classes from one HTMLElement to another
237     * @param Element|Document $from html element classes to be copied from
238     * @param Element|Document $to html element classes to be copied to
239     * @param string[] $enabled array of enabled classes to be copied
240     * @param string[] $additional array of classes to be added/overriden unconditionally
241     *   to the beginning
242     */
243    private function copyClasses( $from, $to, array $enabled, array $additional ) {
244        $styles = HtmlClassUtils::parseClassString(
245            $from->hasAttribute( 'class' ) ? $from->getAttribute( 'class' ) : ''
246        );
247
248        $filteredClasses = HtmlClassUtils::filterAllowedClasses( $styles, $enabled, $additional );
249        $to->setAttribute( 'class', HtmlClassUtils::formClassString( $filteredClasses ) );
250    }
251
252    /**
253     * Fallback for Grade C to load lazy-load image placeholders.
254     *
255     * Note: This will add a single repaint for Grade C browsers as
256     * images enter view but this is intentional and deemed acceptable.
257     *
258     * @return string The JavaScript code to load lazy placeholders in Grade C browsers
259     */
260    public static function gradeCImageSupport() {
261        // Notes:
262        // * Document#getElementsByClassName is supported by IE9+ and #querySelectorAll is
263        // supported by IE8+. To gain the widest possible browser support we scan for
264        // noscript tags using #getElementsByTagName and look at the next sibling.
265        // If the next sibling has the lazy-image-placeholder class then it will be assumed
266        // to be a placeholder and replace with an img tag.
267        // * Iterating over the live NodeList from getElementsByTagName() is suboptimal
268        // but in IE < 9, Array#slice() throws when given a NodeList. It also requires
269        // the 2nd argument ('end').
270        $js = <<<JAVASCRIPT
271(window.NORLQ = window.NORLQ || []).push( function () {
272    var ns, i, p, img;
273    ns = document.getElementsByTagName( 'noscript' );
274    for ( i = 0; i < ns.length; i++ ) {
275        p = ns[i].nextSibling;
276        if ( p && p.className && p.className.indexOf( 'lazy-image-placeholder' ) > -1 ) {
277            img = document.createElement( 'img' );
278            img.setAttribute( 'src', p.getAttribute( 'data-mw-src' ) );
279            img.setAttribute( 'width', p.getAttribute( 'data-width' ) );
280            img.setAttribute( 'height', p.getAttribute( 'data-height' ) );
281            img.setAttribute( 'alt', p.getAttribute( 'data-alt' ) );
282            p.parentNode.replaceChild( img, p );
283        }
284    }
285} );
286JAVASCRIPT;
287        return $js;
288    }
289}