From 99d5c7785f3600d1814c879c452ac7abde9f5b00 Mon Sep 17 00:00:00 2001 From: Kyle Gill Date: Tue, 20 Oct 2020 04:45:52 -0600 Subject: [PATCH] feat(gatsby-plugin-sharp): create image sizes helper (#27554) * wip tests and tweaks to utility * more wip tests * remove file reading from helper * fixing fluid calculations and adding more tests * tweak a little more * remove extraneous export * check that user specified dimensions are positive * remove unnecessary code from review suggestions * add warning, simplify case of no fluid dimensions * add files to warnings and clean up logic * Split into two functions and remove 3x default * Rename warn function and use reporter Co-authored-by: Matt Kane --- .../src/__tests__/utils.js | 238 ++++++++++++++++++ packages/gatsby-plugin-sharp/src/utils.js | 203 ++++++++++----- 2 files changed, 378 insertions(+), 63 deletions(-) diff --git a/packages/gatsby-plugin-sharp/src/__tests__/utils.js b/packages/gatsby-plugin-sharp/src/__tests__/utils.js index e861c5b757d9f..9fdaa0e978721 100644 --- a/packages/gatsby-plugin-sharp/src/__tests__/utils.js +++ b/packages/gatsby-plugin-sharp/src/__tests__/utils.js @@ -2,6 +2,7 @@ jest.mock(`gatsby-cli/lib/reporter`) jest.mock(`progress`) const { createGatsbyProgressOrFallbackToExternalProgressBar, + calculateImageSizes, } = require(`../utils`) const reporter = require(`gatsby-cli/lib/reporter`) const progress = require(`progress`) @@ -39,3 +40,240 @@ describe(`createGatsbyProgressOrFallbackToExternalProgressBar`, () => { expect(bar).toHaveProperty(`total`) }) }) + +const file = { + absolutePath: `~/Usr/gatsby-sites/src/img/photo.png`, +} +const imgDimensions = { + width: 1200, + height: 800, +} + +describe(`calculateImageSizes (fixed)`, () => { + it(`should throw if width is less than 1`, () => { + const args = { + layout: `fixed`, + width: 0, + file, + imgDimensions, + } + const getSizes = () => calculateImageSizes(args) + expect(getSizes).toThrow() + }) + + it(`should throw if height is less than 1`, () => { + const args = { + layout: `fixed`, + height: -50, + file, + imgDimensions, + } + const getSizes = () => calculateImageSizes(args) + expect(getSizes).toThrow() + }) + + it(`should warn if ignored maxWidth or maxHeight are passed in`, () => { + jest.spyOn(global.console, `warn`) + const args = { + layout: `fixed`, + height: 240, + maxWidth: 1000, + maxHeight: 1000, + file, + imgDimensions, + } + calculateImageSizes(args) + expect(console.warn).toBeCalled() + }) + + it(`should return the original width of the image when only width is provided`, () => { + const args = { + layout: `fixed`, + width: 600, + file, + imgDimensions, + } + const sizes = calculateImageSizes(args) + expect(sizes).toContain(600) + }) + + it(`should return the original width of the image when only height is provided`, () => { + const args = { + layout: `fixed`, + height: 500, + file, + imgDimensions, + } + const sizes = calculateImageSizes(args) + expect(sizes).toContain(500 * (imgDimensions.width / imgDimensions.height)) + }) + + it(`should create images of different sizes based on pixel densities with a given width`, () => { + const args = { + layout: `fixed`, + width: 120, + file, + imgDimensions, + } + const sizes = calculateImageSizes(args) + expect(sizes).toEqual(expect.arrayContaining([120, 240])) + }) + + it(`should create images of different sizes based on pixel densities with a given height`, () => { + const args = { + layout: `fixed`, + height: 80, + file, + imgDimensions, + } + const sizes = calculateImageSizes(args) + expect(sizes).toEqual(expect.arrayContaining([120, 240])) + }) +}) + +describe(`calculateImageSizes (fluid & constrained)`, () => { + it(`should throw if maxWidth is less than 1`, () => { + const args = { + layout: `fluid`, + maxWidth: 0, + file, + imgDimensions, + } + const getSizes = () => calculateImageSizes(args) + expect(getSizes).toThrow() + }) + + it(`should throw if maxHeight is less than 1`, () => { + const args = { + layout: `fluid`, + maxHeight: -50, + file, + imgDimensions, + } + const getSizes = () => calculateImageSizes(args) + expect(getSizes).toThrow() + }) + + it(`should warn if ignored width or height are passed in`, () => { + jest.spyOn(global.console, `warn`) + const args = { + layout: `fluid`, + maxWidth: 240, + height: 1000, + width: 1000, + file, + imgDimensions, + } + calculateImageSizes(args) + expect(console.warn).toBeCalled() + }) + + it(`should include the original size of the image when maxWidth is passed`, () => { + const args = { + layout: `fluid`, + maxWidth: 400, + file, + imgDimensions, + } + const sizes = calculateImageSizes(args) + expect(sizes).toContain(400) + }) + + it(`should include the original size of the image when maxWidth is passed for a constrained image`, () => { + const args = { + layout: `constrained`, + maxWidth: 400, + file, + imgDimensions, + } + const sizes = calculateImageSizes(args) + expect(sizes).toContain(400) + }) + + it(`should include the original size of the image when maxHeight is passed`, () => { + const args = { + layout: `fluid`, + maxHeight: 300, + file, + imgDimensions, + } + const sizes = calculateImageSizes(args) + expect(sizes).toContain(450) + }) + + it(`should create images of different sizes (0.25x, 0.5x, 1x, 2x) from a maxWidth`, () => { + const args = { + layout: `fluid`, + maxWidth: 320, + file, + imgDimensions, + } + const sizes = calculateImageSizes(args) + expect(sizes).toEqual(expect.arrayContaining([80, 160, 320, 640])) + }) + + it(`should create images of different sizes (0.25x, 0.5x, 1x) without any defined size provided`, () => { + const args = { + layout: `fluid`, + file, + imgDimensions, + } + const sizes = calculateImageSizes(args) + expect(sizes).toEqual(expect.arrayContaining([200, 400, 800])) + }) + + it(`should return sizes of provided srcSetBreakpoints`, () => { + const srcSetBreakpoints = [50, 70, 150, 250, 300] + const maxWidth = 500 + const args = { + layout: `fluid`, + maxWidth, + srcSetBreakpoints, + file, + imgDimensions, + } + + const sizes = calculateImageSizes(args) + expect(sizes).toEqual(expect.arrayContaining([50, 70, 150, 250, 300, 500])) + }) + + it(`should reject any srcSetBreakpoints larger than the original width`, () => { + const srcSetBreakpoints = [ + 50, + 70, + 150, + 250, + 1250, // shouldn't be included, larger than original width + ] + const maxWidth = 1500 // also shouldn't be included + const args = { + layout: `fluid`, + maxWidth, + srcSetBreakpoints, + file, + imgDimensions, + } + + const sizes = calculateImageSizes(args) + expect(sizes).toEqual(expect.arrayContaining([50, 70, 150, 250])) + expect(sizes).toEqual(expect.not.arrayContaining([1250, 1500])) + }) + + it(`should only uses sizes from srcSetBreakpoints when outputPixelDensities are also passed in`, () => { + jest.spyOn(global.console, `warn`) + const srcSetBreakpoints = [400, 800] // should find these + const maxWidth = 500 + const args = { + layout: `fluid`, + maxWidth, + outputPixelDensities: [2, 4], // and ignore these, ie [1000, 2000] + srcSetBreakpoints, + file, + imgDimensions, + } + + const sizes = calculateImageSizes(args) + expect(sizes).toEqual(expect.arrayContaining([400, 500, 800])) + expect(console.warn).toBeCalled() + }) +}) diff --git a/packages/gatsby-plugin-sharp/src/utils.js b/packages/gatsby-plugin-sharp/src/utils.js index 353d6e04a5eaf..97116b04c0d84 100644 --- a/packages/gatsby-plugin-sharp/src/utils.js +++ b/packages/gatsby-plugin-sharp/src/utils.js @@ -1,3 +1,5 @@ +import reporter from "gatsby-cli/lib/reporter" + const ProgressBar = require(`progress`) // TODO remove in V3 @@ -88,87 +90,162 @@ export function rgbToHex(red, green, blue) { .slice(1)}` } -export const calculateImageSizes = ({ +const warnForIgnoredParameters = (layout, parameters, filepath) => { + const ignoredParams = Object.entries(parameters).filter(([_, value]) => + Boolean(value) + ) + if (ignoredParams.length) { + reporter( + `The following provided parameter(s): ${ignoredParams + .map(param => param.join(`: `)) + .join( + `, ` + )} for the image at ${filepath} are ignored in ${layout} image layouts.` + ) + } + return +} + +const DEFAULT_PIXEL_DENSITIES = [0.25, 0.5, 1, 2] +const DEFAULT_FLUID_SIZE = 800 + +const dedupeAndSortDensities = values => + Array.from(new Set([1, ...values])).sort() + +export function calculateImageSizes(args) { + const { width, maxWidth, height, maxHeight, file, layout } = args + + // check that all dimensions provided are positive + const userDimensions = { width, maxWidth, height, maxHeight } + const erroneousUserDimensions = Object.entries(userDimensions).filter( + ([_, size]) => typeof size === `number` && size < 1 + ) + if (erroneousUserDimensions.length) { + throw new Error( + `Specified dimensions for images must be positive numbers (> 0). Problem dimensions you have are ${erroneousUserDimensions + .map(dim => dim.join(`: `)) + .join(`, `)}` + ) + } + + if (layout === `fixed`) { + return fixedImageSizes(args) + } else if (layout === `fluid` || layout === `constrained`) { + return fluidImageSizes(args) + } else { + reporter.warn( + `No valid layout was provided for the image at ${file.absolutePath}. Valid image layouts are fixed, fluid, and constrained.` + ) + return [] + } +} +export function fixedImageSizes({ file, + imgDimensions, width, maxWidth, height, maxHeight, - layout, - outputPixelDensities, + outputPixelDensities = DEFAULT_PIXEL_DENSITIES, srcSetBreakpoints, -}) => { - // Sort, dedupe and ensure there's a 1 - const densities = Array.from(new Set([1, ...outputPixelDensities])).sort() +}) { let sizes - if (layout === `fixed`) { - // FIXED - // if no width is passed, we need to resize the image based on the passed height - const fixedDimension = width === undefined ? `height` : `width` + const aspectRatio = imgDimensions.width / imgDimensions.height + // Sort, dedupe and ensure there's a 1 + const densities = dedupeAndSortDensities(outputPixelDensities) - if (!width) { - width = height * file.aspectRatio - } + warnForIgnoredParameters(`fixed`, { maxWidth, maxHeight }, file.absolutePath) - sizes = densities - .map(density => density * width) - .filter(size => size <= width) + // if no width is passed, we need to resize the image based on the passed height + if (!width) { + width = height * aspectRatio + } + + sizes = densities + .filter(size => size >= 1) // remove smaller densities because fixed images don't need them + .map(density => density * width) + .filter(size => size <= imgDimensions.width) - // If there's no fluid images after filtering (e.g. image is smaller than what's - // requested, add back the original so there's at least something) - if (sizes.length === 0) { - sizes.push(width) - console.warn(` + // If there's no fixed images after filtering (e.g. image is smaller than what's + // requested, add back the original so there's at least something) + if (sizes.length === 0) { + sizes.push(width) + const fixedDimension = width === undefined ? `height` : `width` + reporter(` The requested ${fixedDimension} "${ - fixedDimension === `width` ? width : height - }px" for a resolutions field for + fixedDimension === `width` ? width : height + }px" for a resolutions field for the file ${file.absolutePath} was larger than the actual image ${fixedDimension} of ${ - file[fixedDimension] - }px! + imgDimensions[fixedDimension] + }px! If possible, replace the current image with a larger one. `) - } - } else { - // FLUID - // if no maxWidth is passed, we need to resize the image based on the passed maxHeight - const fixedDimension = maxWidth === undefined ? `maxHeight` : `maxWidth` - maxWidth = maxWidth - ? Math.min(maxWidth, width) - : maxHeight * file.aspectRatio - maxHeight = maxHeight ? Math.min(maxHeight, height) : undefined - const fixedValue = fixedDimension === `maxHeight` ? maxHeight : maxWidth - if (fixedValue < 1) { - throw new Error( - `${fixedDimension} has to be a positive int larger than zero (> 0), now it's ${fixedValue}` + } + return sizes +} + +export function fluidImageSizes({ + file, + imgDimensions, + width, + maxWidth, + height, + maxHeight, + outputPixelDensities = DEFAULT_PIXEL_DENSITIES, + srcSetBreakpoints, +}) { + // warn if ignored parameters are passed in + warnForIgnoredParameters( + `fluid and constrained`, + { width, height }, + file.absolutePath + ) + let sizes + const aspectRatio = imgDimensions.width / imgDimensions.height + // Sort, dedupe and ensure there's a 1 + const densities = dedupeAndSortDensities(outputPixelDensities) + + // Case 1: maxWidth of maxHeight were passed in, make sure it isn't larger than the actual image + maxWidth = maxWidth && Math.min(maxWidth, imgDimensions.width) + maxHeight = maxHeight && Math.min(maxHeight, imgDimensions.height) + + // Case 2: neither maxWidth or maxHeight were passed in, use default size + if (!maxWidth && !maxHeight) { + maxWidth = Math.min(DEFAULT_FLUID_SIZE, imgDimensions.width) + maxHeight = maxWidth / aspectRatio + } + + // if it still hasn't been found, calculate maxWidth from the derived maxHeight + if (!maxWidth) { + maxWidth = maxHeight * aspectRatio + } + + // Create sizes (in width) for the image if no custom breakpoints are + // provided. If the max width of the container for the rendered markdown file + // is 800px, the sizes would then be: 200, 400, 800, 1600 if using + // the default outputPixelDensities + // + // This is enough sizes to provide close to the optimal image size for every + // device size / screen resolution while (hopefully) not requiring too much + // image processing time (Sharp has optimizations thankfully for creating + // multiple sizes of the same input file) + if (srcSetBreakpoints) { + sizes = srcSetBreakpoints.filter(size => size <= imgDimensions.width) + if (outputPixelDensities) { + reporter( + `outputPixelDensities of ${outputPixelDensities} were passed into the image at ${file.absolutePath} with srcSetBreakpoints, srcSetBreakpoints will override the effect of outputPixelDensities` ) } + } else { + sizes = densities.map(density => density * maxWidth) + sizes = sizes.filter(size => size <= imgDimensions.width) + } - // Create sizes (in width) for the image if no custom breakpoints are - // provided. If the max width of the container for the rendered markdown file - // is 800px, the sizes would then be: 200, 400, 800, 1200, 1600. - // - // This is enough sizes to provide close to the optimal image size for every - // device size / screen resolution while (hopefully) not requiring too much - // image processing time (Sharp has optimizations thankfully for creating - // multiple sizes of the same input file) - - // use standard breakpoints if no custom breakpoints are specified - if (!srcSetBreakpoints || !srcSetBreakpoints.length) { - sizes = densities - .map(density => density * width) - .filter(size => size <= maxWidth) - if (!sizes.includes(maxWidth)) { - sizes.push(maxWidth) - } - sizes = sizes.sort() - } else { - let sizes = srcSetBreakpoints.filter(size => size <= maxWidth) - if (!sizes.includes(maxWidth)) { - sizes.push(maxWidth) - } - sizes = sizes.sort() - } + // ensure that the size passed in is included in the final output + if (!sizes.includes(maxWidth)) { + sizes.push(maxWidth) } + sizes = sizes.sort((a, b) => a - b) return sizes }