/** * Copyright 2020 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. */ 'use strict'; const {hasAttribute, nextNode, firstChildByTag} = require('../NodeUtils'); const {skipNodeAndChildren} = require('../HtmlDomHelper'); const {isValidImageSrcURL} = require('../URLUtils'); const {isTemplate} = require('../AmpConstants'); // Don't generate srcset's for images with width smaller than MIN_WIDTH_TO_ADD_SRCSET_IN_RESPONSIVE_LAYOUT // this avoids generating srcsets for images with a responsive layout where width/height define the aspect ration. const MIN_WIDTH_TO_ADD_SRCSET_IN_RESPONSIVE_LAYOUT = 100; // All supported srcset widths. const SRCSET_WIDTH = [ 39, 47, 56, 68, 82, 100, 120, 150, 180, 220, 270, 330, 390, 470, 560, 680, 820, 1000, 1200, 1440, 1750, 2000, 2500, ]; // Don't generate srcsets for images larger than the supported maximum const MAX_IMG_SIZE = SRCSET_WIDTH[SRCSET_WIDTH - 1]; // The maximum number of srcset source. We'll take the initial image width and generate more width values by // multiplying by multiples of 1.0 up the given max value (e.g. width=300, maxSrcsetValues=3 => 1 * 300, 2 * 300, 3 * 300) // and match the result to the closest supported srcset width (see above). const MAX_SRCSET_VALUE_COUNT = 3; /** * Calculates the srcset width for a given image width. */ class SrcsetWidth { constructor(imgSrcWidth, maxImgWidth = -1, maxSrcsetValues = MAX_SRCSET_VALUE_COUNT) { this.widthList_ = []; this.setBaseWidth(imgSrcWidth, maxImgWidth, maxSrcsetValues); } /** * Sets the base width, i.e., rendered dimension measured in CSS pixels. * Returns true if srcset is needed, that is, we'll resize the image to at * least 2 supported widths (@see SRCSET_WIDTH for a list of supported widths). * * If maxImgWidth is provided the actual image size in srcset will not * exceed this value. So if maxImgWidth is 820, the srcset will not * contain any image greater than 820px. The maxImgWidth is not absolute * number but depends on the aspect ratio. So if 650 is maxImgWidth, the * nearest aspect ratio width for this max width is 620. * * @param {Number} imgSrcWidth * @param {Number} maxImgWidth */ setBaseWidth(imgSrcWidth, maxImgWidth = -1, maxSrcsetValues = MAX_SRCSET_VALUE_COUNT) { this.widthList_.length = 0; let previousWidth = -1; if (maxImgWidth > 0 && imgSrcWidth > maxImgWidth) { return; } for (let i = maxSrcsetValues; i > 0; --i) { let width = this.roundUp(imgSrcWidth * i); if (maxImgWidth > 0 && width > maxImgWidth) { width = maxImgWidth; } if (width != previousWidth) { this.widthList_.push(width); } previousWidth = width; } } /** * Returns true if there are more width values. */ moreWidth() { return this.widthList_.length > 0; } /** * Returns the current legitimate width and moves the state to the next one. */ nextWidth() { return this.widthList_.pop(); } /** * */ isValid() { return this.widthList_.length > 1; } roundUp(value) { for (const width of SRCSET_WIDTH) { if (width > value) { return width; } } return SRCSET_WIDTH[SRCSET_WIDTH.length - 1]; } } /** * ImageTransformer - generates srcset attribute for amp-img. * * This transformer requires the following option: * * - `imageOptimizer`: a function for customizing the srcset generation. The function should return a URL * pointing to a version of the `src` image with the given `width`. If no image is available, it should * return a falsy value. For example: (src, width) => `${src}?width=${width}`. */ class OptimizeImages { constructor(config) { this.log = config.log; this.imageOptimizer = config.imageOptimizer; // TODO turn these into options https://github.com/ampproject/amp-toolbox/issues/804 this.maxImageWidth = MAX_IMG_SIZE; this.maxSrcsetValues = MAX_SRCSET_VALUE_COUNT; } async transform(root) { if (!this.imageOptimizer) { return; } const html = firstChildByTag(root, 'html'); const body = firstChildByTag(html, 'body'); let node = body; const imageOptimizationPromises = []; while (node !== null) { if (isTemplate(node)) { node = skipNodeAndChildren(node); } else { if (node.tagName === 'amp-img') { imageOptimizationPromises.push(this.optimizeImage(node)); } node = nextNode(node); } } return Promise.all(imageOptimizationPromises); } async optimizeImage(imageNode) { // Don't change existing srcsets. if (hasAttribute(imageNode, 'srcset')) { return; } // Should not happen for valid AMP. if (!hasAttribute(imageNode, 'src')) { return; } const src = imageNode.attribs.src; // Check if it's a relative path or a valid http(s) URL. if (!isValidImageSrcURL(src)) { return; } // No srcset is added if the image ends with a `,` (comma). See // http://b/127535381 for context. if (src.endsWith(',')) { return; } const width = imageNode.attribs.width; // TODO(b/113271759): Handle width values that include 'px' (probably others). if (isNaN(Number.parseInt(width))) { // No width or invalid width. return; } // Determine if the layout is "responsive". const {layout, height, sizes} = imageNode.attribs; const isResponsive = layout === 'responsive' || (!layout && height && sizes); // In responsive layout, width and height might be used for indicating // the aspect ratio instead of the actual render dimensions. This usually // happens for dimensions of small values. if (isResponsive && width < MIN_WIDTH_TO_ADD_SRCSET_IN_RESPONSIVE_LAYOUT) { return; } // We add srcset only when the CSS dimensions correspond to 2 or more // unique legitimate physical dimensions. const srcsetWidth = new SrcsetWidth(width, this.maxImageWidth, this.maxSrcsetValues); if (!srcsetWidth.isValid()) { return; } // Generate the srcset. let srcset = ''; while (srcsetWidth.moreWidth()) { const nextWidth = srcsetWidth.nextWidth(); try { // Generate the width specific image URL using the default or custom srcset generator. const nextSrc = await this.imageOptimizer(src, nextWidth); // Add the width (if supported) to the srcset. if (nextSrc) { if (!srcsetWidth.moreWidth()) { // The last value should be specified without a width descriptor // https://github.com/kristoferbaxter/srcset-broken srcset += nextSrc; } else { srcset += `${nextSrc} ${nextWidth}w, `; } } } catch (e) { this.log.error('Exception when optimizing image', src, e); } } if (srcset) { imageNode.attribs.srcset = srcset; this.log.debug('Generating img srcset', src, imageNode.attribs.srcset); } } } module.exports = OptimizeImages;