Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
89.13% |
82 / 92 |
|
60.00% |
6 / 10 |
CRAP | |
0.00% |
0 / 1 |
LazyImageTransform | |
89.13% |
82 / 92 |
|
60.00% |
6 / 10 |
36.57 | |
0.00% |
0 / 1 |
__construct | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
apply | |
100.00% |
6 / 6 |
|
100.00% |
1 / 1 |
3 | |||
getImageDimension | |
100.00% |
7 / 7 |
|
100.00% |
1 / 1 |
4 | |||
getImageDimensions | |
100.00% |
6 / 6 |
|
100.00% |
1 / 1 |
3 | |||
isDimensionSmallerThanThreshold | |
90.91% |
10 / 11 |
|
0.00% |
0 / 1 |
5.02 | |||
skipLazyLoadingForSmallDimensions | |
0.00% |
0 / 7 |
|
0.00% |
0 / 1 |
30 | |||
doRewriteImagesForLazyLoading | |
97.67% |
42 / 43 |
|
0.00% |
0 / 1 |
9 | |||
copyStyles | |
100.00% |
5 / 5 |
|
100.00% |
1 / 1 |
2 | |||
copyClasses | |
100.00% |
5 / 5 |
|
100.00% |
1 / 1 |
2 | |||
gradeCImageSupport | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 |
1 | <?php |
2 | |
3 | namespace MobileFrontend\Transforms; |
4 | |
5 | use DOMDocument; |
6 | use DOMElement; |
7 | use MobileFrontend\Transforms\Utils\HtmlClassUtils; |
8 | use MobileFrontend\Transforms\Utils\HtmlStyleUtils; |
9 | use Wikimedia\Parsoid\Utils\DOMCompat; |
10 | |
11 | class 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 | } ); |
289 | JAVASCRIPT; |
290 | return $js; |
291 | } |
292 | } |