/** * Copyright 2015 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. * * Credits: ported from https://github.com/ampproject/amphtml/blob/1098976cdb0bb73ad89f3287185df992bb29f69c/src/srcset.js */ /** * A single source within a srcset. Only one: width or DPR can be specified at * a time. * @typedef {{ * url: string, * width: (number|undefined), * dpr: (number|undefined) * }} */ // eslint-disable-next-line let SrcsetSourceDef; /** * General grammar: (URL [NUM[w|x]],)* * Example 1: "image1.png 100w, image2.png 50w" * Example 2: "image1.png 2x, image2.png" * Example 3: "image1,100w.png 100w, image2.png 50w" */ const srcsetRegex = /(\S+)(?:\s+(?:(-?\d+(?:\.\d+)?)([a-zA-Z]*)))?\s*(?:,|$)/g; /** * Parses the text representation of srcset into Srcset object. * See https://developer.mozilla.org/en-US/docs/Web/HTML/Element/img#Attributes. * See http://www.w3.org/html/wg/drafts/html/master/semantics.html#attr-img-srcset. * @param {string} s * @return {Srcset} */ function parseSrcset(s) { const sources = []; let match; while ((match = srcsetRegex.exec(s))) { const url = match[1]; let width, dpr; if (match[2]) { const type = match[3].toLowerCase(); if (type == 'w') { width = parseInt(match[2], 10); } else if (type == 'x') { dpr = parseFloat(match[2]); } else { continue; } } else { // If no "w" or "x" specified, we assume it's "1x". dpr = 1; } sources.push({url, width, dpr}); } return new Srcset(sources); } /** * A srcset object contains one or more sources. * * There are two types of sources: width-based and DPR-based. Only one type * of sources allowed to be specified within a single srcset. Depending on a * usecase, the components are free to choose any source that best corresponds * to the required rendering quality and network and CPU conditions. See * "select" method for details on how this selection is performed. * * See https://developer.mozilla.org/en-US/docs/Web/HTML/Element/img#Attributes */ class Srcset { /** * @param {!Array} sources */ constructor(sources) { assert(sources.length > 0, 'Srcset must have at least one source'); /** @private @const {!Array} */ this.sources_ = sources; // Only one type of source specified can be used - width or DPR. let hasWidth = false; let hasDpr = false; for (let i = 0; i < sources.length; i++) { const source = sources[i]; hasWidth = hasWidth || !!source.width; hasDpr = hasDpr || !!source.dpr; } assert(!!(hasWidth ^ hasDpr), 'Srcset must have width or dpr sources, but not both'); // Source and assert duplicates. sources.sort(hasWidth ? sortByWidth : sortByDpr); /** @private @const {boolean} */ this.widthBased_ = hasWidth; } /** * Performs selection for specified width and DPR. Here, width is the width * in screen pixels and DPR is the device-pixel-ratio or pixel density of * the device. Depending on the circumstances, such as low network conditions, * it's possible to manipulate the result of this method by passing a lower * DPR value. * * The source selection depends on whether this is width-based or DPR-based * srcset. * * In a width-based source, the source's width is the physical width of a * resource (e.g. an image). Depending on the provided DPR, this width is * converted to the screen pixels as following: * pixelWidth = sourceWidth / DPR * * Then, the source closest to the requested "width" is selected using * the "pixelWidth". The slight preference is given to the bigger sources to * ensure the most optimal quality. * * In a DPR-based source, the source's DPR is used to return the source that * is closest to the requested DPR. * * Based on * http://www.w3.org/html/wg/drafts/html/master/semantics.html#attr-img-srcset. * @param {number} width * @param {number} dpr * @return {string} */ select(width, dpr) { assert(width, 'width=%s', width); assert(dpr, 'dpr=%s', dpr); let index = 0; if (this.widthBased_) { index = this.selectByWidth_(width * dpr); } else { index = this.selectByDpr_(dpr); } return this.sources_[index].url; } /** * @param {number} width * @return {number} * @private */ selectByWidth_(width) { const sources = this.sources_; let minIndex = 0; let minScore = Infinity; let minWidth = Infinity; for (let i = 0; i < sources.length; i++) { const sWidth = sources[i].width; const score = Math.abs(sWidth - width); // Select the one that is closer with a slight preference toward larger // widths. If smaller size is closer, enforce minimum ratio to ensure // image isn't too distorted. if (score <= minScore * 1.1 || width / minWidth > 1.2) { minIndex = i; minScore = score; minWidth = sWidth; } else { break; } } return minIndex; } /** * @param {number} dpr * @return {number} * @private */ selectByDpr_(dpr) { const sources = this.sources_; let minIndex = 0; let minScore = Infinity; for (let i = 0; i < sources.length; i++) { const score = Math.abs(sources[i].dpr - dpr); if (score <= minScore) { minIndex = i; minScore = score; } else { break; } } return minIndex; } /** * Returns all URLs in the srcset. * @return {!Array} */ getUrls() { return this.sources_.map((s) => s.url); } /** * Reconstructs the string expression for this srcset. * @param {function(string):string=} opt_mapper * @return {string} */ stringify(opt_mapper) { const res = []; const sources = this.sources_; for (let i = 0; i < sources.length; i++) { const source = sources[i]; let src = source.url; if (opt_mapper) { src = opt_mapper(src); } if (this.widthBased_) { src += ` ${source.width}w`; } else { src += ` ${source.dpr}x`; } res.push(src); } return res.join(', '); } } /** * Sorts by width * * @param {number} s1 * @param {number} s2 * @return {number} */ function sortByWidth(s1, s2) { assert(s1.width != s2.width, 'Duplicate width: %s', s1.width); return s1.width - s2.width; } /** * Sorts by dpr * * @param {!Object} s1 * @param {!Object} s2 * @return {number} */ function sortByDpr(s1, s2) { assert(s1.dpr != s2.dpr, 'Duplicate dpr: %s', s1.dpr); return s1.dpr - s2.dpr; } function assert(shouldBeTrueish, opt_message) { let firstElement; if (!shouldBeTrueish) { const message = opt_message || 'Assertion failed'; const splitMessage = message.split('%s'); const first = splitMessage.shift(); let formatted = first; const messageArray = []; let i = 2; pushIfNonEmpty(messageArray, first); while (splitMessage.length > 0) { const nextConstant = splitMessage.shift(); const val = arguments[i++]; if (val && val.tagName) { firstElement = val; } messageArray.push(val); pushIfNonEmpty(messageArray, nextConstant.trim()); formatted += stringOrElementString(val) + nextConstant; } const e = new Error(formatted); e.fromAssert = true; e.associatedElement = firstElement; e.messageArray = messageArray; throw e; } return shouldBeTrueish; } /** * @param {string|!Element} val * @return {string} */ const stringOrElementString = (val) => /** @type {string} */ elementStringOrPassthru(val); /** * @param {*} val * @return {*} */ function elementStringOrPassthru(val) { // Do check equivalent to `val instanceof Element` without cross-window bug if (val && val.nodeType == 1) { return val.tagName.toLowerCase() + (val.id ? '#' + val.id : ''); } return val; } function pushIfNonEmpty(array, val) { if (val != '') { array.push(val); } } module.exports = parseSrcset;