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