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