-
Notifications
You must be signed in to change notification settings - Fork 244
/
Copy pathAddBlurryImagePlaceholders.js
343 lines (317 loc) · 12.3 KB
/
AddBlurryImagePlaceholders.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
/**
* Copyright 2018 The AMP HTML Authors. All Rights Reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS-IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
const isDependencyInstalled = require('../isDependencyInstalled');
const {createElement, appendChild, nextNode, firstChildByTag} = require('../NodeUtils');
const {URL} = require('url');
const {skipNodeAndChildren} = require('../HtmlDomHelper');
const PathResolver = require('../PathResolver');
const PIXEL_TARGET = 60;
const MAX_BLURRED_PLACEHOLDERS = 100;
const DEFAULT_CACHED_PLACEHOLDERS = 30;
const CACHE_ALL_PLACEHOLDERS = -1;
const ESCAPE_TABLE = {
'#': '%23',
'%': '%25',
':': '%3A',
'<': '%3C',
'>': '%3E',
'"': "'",
};
const ESCAPE_REGEX = new RegExp(Object.keys(ESCAPE_TABLE).join('|'), 'g');
function escaper(match) {
return ESCAPE_TABLE[match];
}
/**
* Adds placeholders for certain amp-img's and posters for amp-videos that are
* blurry versions of the corresponding original source. The blur will be
* displayed as the <amp-img> is rendering, and will fade out once the element
* is loaded. The current requirements of appending a blurry placeholder is for
* the element is to be a JPEG that is either responsive or a poster for an
* amp-video.
*
* This transformer supports the following option:
*
* * `blurredPlaceholders`: Enables blurry image placeholder generation. Default is `false`.
* * `imageBasePath`: specifies a base path used to resolve an image during build. You can
* also pass a function `(imgSrc, params) => '../img/' + imgSrc` for calculating the image path.
* * `maxBlurredPlaceholders`: Specifies the max number of blurred images. Defaults to 5.
* * `blurredPlaceholdersCacheSize`: Specifies the max number of blurred images to be cached
* to avoid expensive recalculation. Set to 0 if caching should be disabled. Set to -1 if
* all placeholders should be cached (good for static sites). Defaults to 30.
*
* Important: blurry image placeholder computation is expensive. Make sure to
* only use it for static or cached pages.
*/
class AddBlurryImagePlaceholders {
constructor(config) {
this.log_ = config.log.tag('AddBlurryImagePlaceholders');
// setup implementation only if placeholder generation is enabled
this.blurredPlaceholders_ = !!config.blurredPlaceholders;
if (!this.blurredPlaceholders_) {
this.log_.debug('disabled');
return;
}
// check whether all required dependencies are installed
if (!isDependencyInstalled('jimp') || !isDependencyInstalled('lru-cache')) {
this.log_.warn(
'jimp and lru-cache need to be installed via `npm install jimp lru-cache` ' +
'for this transformer to work'
);
// we can't generate placeholders
this.blurredPlaceholders_ = false;
return;
}
this.jimp = require('jimp');
// use provided upper placeholder limit for fallback to default
this.maxBlurredPlaceholders_ = config.maxBlurredPlaceholders || MAX_BLURRED_PLACEHOLDERS;
// used for resolving image files
this.pathResolver_ = new PathResolver(config.imageBasePath);
// setup caching
const maxCacheSize = config.blurredPlaceholdersCacheSize || DEFAULT_CACHED_PLACEHOLDERS;
// use a Map if all placeholders should be cached
if (maxCacheSize === CACHE_ALL_PLACEHOLDERS) {
this.log_.debug('caching all placeholders');
this.cache_ = new Map();
} else if (maxCacheSize > 0) {
const LRU = require('lru-cache');
this.log_.debug('using LRU cache for regularily used placeholders', maxCacheSize);
// use a LRU cache otherwise
this.cache_ = new LRU({
max: maxCacheSize,
});
} else {
this.log_.debug('caching disabled');
}
}
/**
* Parses the document to add blurred placedholders in all appropriate
* locations.
* @param {Object} runtime parameters
* @return {Array} An array of promises that all represents the resolution of
* a blurred placeholder being added in an appropriate place.
*/
transform(root, params) {
// Check if placeholders should be generated
if (!this.blurredPlaceholders_) {
return;
}
const html = firstChildByTag(root, 'html');
const body = firstChildByTag(html, 'body');
const promises = [];
let placeholders = 0;
for (let node = body; node !== null; node = nextNode(node)) {
const {tagName} = node;
let src;
if (tagName === 'template') {
node = skipNodeAndChildren(node);
continue;
}
if (tagName === 'amp-img') {
src = node.attribs.src;
}
if (tagName === 'amp-video' && node.attribs.poster) {
src = node.attribs.poster;
}
if (this.shouldAddBlurryPlaceholder_(node, src, tagName)) {
placeholders++;
const promise = this.addBlurryPlaceholder_(src, params).then((img) => {
node.attribs.noloading = '';
appendChild(node, img);
});
promises.push(promise);
if (placeholders >= this.maxBlurredPlaceholders_) {
break;
}
}
}
return Promise.all(promises);
}
/**
* Adds a child image that is a blurry placeholder.
* @param {String} src The image that the bitmap is based on.
* @return {!Promise} A promise that signifies that the img has been updated
* to have correct attributes to be a blurred placeholder along with the
* placeholder itself.
* @private
*/
async addBlurryPlaceholder_(src, params) {
const img = createElement('img', {
class: 'i-amphtml-blurry-placeholder',
placeholder: '',
src,
alt: '',
});
try {
const dataURI = await this.getCachedDataURI(src, params);
let svg = `<svg xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink"
viewBox="0 0 ${dataURI.width} ${dataURI.height}">
<filter id="b" color-interpolation-filters="sRGB">
<feGaussianBlur stdDeviation=".5"></feGaussianBlur>
<feComponentTransfer>
<feFuncA type="discrete" tableValues="1 1"></feFuncA>
</feComponentTransfer>
</filter>
<image filter="url(#b)" x="0" y="0"
height="100%" width="100%"
xlink:href="${dataURI.src}">
</image>
</svg>`;
// Optimizes dataURI length by deleting line breaks, and
// removing unnecessary spaces.
svg = svg.replace(/\s+/g, ' ');
svg = svg.replace(/> </g, '><');
svg = svg.replace(ESCAPE_REGEX, escaper);
img.attribs.src = 'data:image/svg+xml;charset=utf-8,' + svg;
this.log_.debug(src, '[SUCCESS]');
} catch (err) {
this.log_.debug(src, '[FAIL]');
this.log_.error(err.message);
}
return img;
}
/**
* Returns a cached dataURI if exists, otherwise creates a new one.
*/
getCachedDataURI(src, params) {
const resolvedSrc = this.pathResolver_.resolve(src, params);
if (this.cache_) {
const dataURIPromise = this.cache_.get(resolvedSrc);
if (dataURIPromise) {
this.log_.debug(src, '[CACHE HIT]');
return dataURIPromise;
}
this.log_.debug(src, '[CACHE MISS]');
}
const dataURIPromise = this.getDataURI_(resolvedSrc);
if (this.cache_) {
// we cache the promise to ensure that multiple requests for the same image
// still use the cache
this.cache_.set(resolvedSrc, dataURIPromise);
}
return dataURIPromise;
}
/**
* Creates the bitmap in a dataURI format.
* @param {string} the img src value
* placeholder.
* @return {!Promise} A promise that is resolved once the img's src is updated
* to be a dataURI of a bitmap including width and height.
* @private
*/
async getDataURI_(src) {
const image = await this.jimp.read(src);
const imgDimension = this.getBitmapDimensions_(image.bitmap.width, image.bitmap.height);
image.resize(imgDimension.width, imgDimension.height, this.jimp.RESIZE_BEZIER);
const result = {
src: await image.getBase64Async('image/png'),
width: image.bitmap.width,
height: image.bitmap.height,
};
return result;
}
/**
* Calculates the correct dimensions for the bitmap.
* @param {Node} img The DOM element that will need a bitmap.
* placeholder.
* @return {Record} The aspect ratio of the bitmap of the image.
* @private
*/
getBitmapDimensions_(imgWidth, imgHeight) {
// Aims for a bitmap of ~P pixels (w * h = ~P).
// Gets the ratio of the width to the height. (r = w0 / h0 = w / h)
const ratioWH = imgWidth / imgHeight;
// Express the width in terms of height by multiply the ratio by the
// height. (h * r = (w / h) * h)
// Plug this representation of the width into the original equation.
// (h * r * h = ~P).
// Divide the bitmap size by the ratio to get the all expressions using
// height on one side. (h * h = ~P / r)
let bitmapHeight = PIXEL_TARGET / ratioWH;
// Take the square root of the height instances to find the singular value
// for the height. (h = sqrt(~P / r))
bitmapHeight = Math.sqrt(bitmapHeight);
// Divide the goal total pixel amount by the height to get the width.
// (w = ~P / h).
const bitmapWidth = PIXEL_TARGET / bitmapHeight;
return {width: Math.round(bitmapWidth), height: Math.round(bitmapHeight)};
}
/**
* Checks if an element has a placeholder.
* @param {Node} node The DOM element that is being checked for a placeholder.
* @return {boolean} Whether or not the element already has a placeholder
* child.
* @private
*/
hasPlaceholder_(node) {
return (
node.childNodes.find((child) => {
return child.attribs && child.attribs.placeholder !== undefined;
}) !== undefined
);
}
/**
* Checks if an image should have a blurred image placeholder.
* The current criteria for determining if a blurry image placeholder should
* be appended is as follows:
* - The source for the image should be a JPEG.
* - If the element is:
* - an amp-img using a responsive layout (responsive, fill or intrinsic)
* - an amp-video with a poster
*
* This criteria was found to be the most common places where a blurry image
* placeholder would likely want to be used through manual examination of
* existing AMP pages.
* @param {Node} node The DOM element that is being checked to see if it
* should have a blurred placeholder.
* @param {string} src The image source that is being checked.
* @param {string} tagName The type of element that is being checked.
* @return {boolean} Whether or not the element should have a blurred
* placeholder child.
* @private
*/
shouldAddBlurryPlaceholder_(node, src, tagName) {
// Ensures current placeholders are not overridden.
if (!src) {
return false;
}
if (this.hasPlaceholder_(node)) {
return false;
}
// Non-JPEG images are not commonly featured in a role where blurred
// image placeholders would be wanted.
const url = new URL(src, 'https://example.com');
if (!url.pathname.endsWith('.jpg') && !url.pathname.endsWith('jpeg')) {
return false;
}
// Images or videos with noloading attributes should not have any indicators that they
// are loading.
if (node.attribs.noloading != null) {
return false;
}
// Checks if the image is a poster or a responsive image as these are the
// two most common cases where blurred placeholders would be wanted.
const isPoster = tagName == 'amp-video';
const isResponsiveImgWithLoading =
tagName == 'amp-img' &&
(node.attribs.layout == 'intrinsic' ||
node.attribs.layout == 'responsive' ||
node.attribs.layout == 'fill');
return isPoster || isResponsiveImgWithLoading;
}
}
/** @module AddBlurryImagePlaceholders */
module.exports = AddBlurryImagePlaceholders;