UNPKG

@11ty/eleventy-img

Version:

Low level utility to perform build-time image transformations.

931 lines (769 loc) 30.4 kB
const fs = require("node:fs"); const fsp = fs.promises; const path = require("node:path"); const getImageSize = require("image-size"); const debugUtil = require("debug"); const { createHashSync } = require("@11ty/eleventy-utils"); const { Fetch } = require("@11ty/eleventy-fetch"); const sharp = require("./adapters/sharp.js"); const brotliSize = require("./adapters/brotli-size.js"); const Util = require("./util.js"); const ImagePath = require("./image-path.js"); const generateHTML = require("./generate-html.js"); const GLOBAL_OPTIONS = require("./global-options.js").defaults; const { existsCache, memCache, diskCache } = require("./caches.js"); const debug = debugUtil("Eleventy:Image"); const debugAssets = debugUtil("Eleventy:Assets"); const MIME_TYPES = { "jpeg": "image/jpeg", "webp": "image/webp", "png": "image/png", "svg": "image/svg+xml", "avif": "image/avif", "gif": "image/gif", }; const FORMAT_ALIASES = { "jpg": "jpeg", // if you’re working from a mime type input, let’s alias it back to svg "svg+xml": "svg", }; const ANIMATED_TYPES = [ "webp", "gif", ]; const TRANSPARENCY_TYPES = [ "avif", "png", "webp", "gif", "svg", ]; const MINIMUM_TRANSPARENCY_TYPES = [ "png", "gif", "svg", ]; class Image { #input; #contents = {}; #queue; #queuePromise; #buildLogger; #computedHash; #directoryManager; constructor(src, options = {}) { if(!src) { throw new Error("`src` is a required argument to the eleventy-img utility (can be a String file path, String URL, or Buffer)."); } this.src = src; this.isRemoteUrl = typeof src === "string" && Util.isRemoteUrl(src); this.rawOptions = options; this.options = Object.assign({}, GLOBAL_OPTIONS, options); // Compatible with eleventy-dev-server and Eleventy 3.0.0-alpha.7+ in serve mode. if(this.options.transformOnRequest && !this.options.urlFormat) { this.options.urlFormat = function({ src, width, format }/*, imageOptions*/, options) { return `/.11ty/image/?src=${encodeURIComponent(src)}&width=${width}&format=${format}${options.generatedVia ? `&via=${options.generatedVia}` : ""}`; }; this.options.statsOnly = true; } if(this.isRemoteUrl) { this.cacheOptions = Object.assign({ type: "buffer", // deprecated in Eleventy Image, but we already prefer this.cacheOptions.duration automatically duration: this.options.cacheDuration, // Issue #117: re-use eleventy-img dryRun option value for eleventy-fetch dryRun dryRun: this.options.dryRun, }, this.options.cacheOptions); // v6.0.0 this now inherits eleventy-fetch option defaults this.assetCache = Fetch(src, this.cacheOptions); } } setQueue(queue) { this.#queue = queue; } setBuildLogger(buildLogger) { this.#buildLogger = buildLogger; } setDirectoryManager(manager) { this.#directoryManager = manager; } get directoryManager() { if(!this.#directoryManager) { throw new Error("Missing #directoryManager"); } return this.#directoryManager; } get buildLogger() { if(!this.#buildLogger) { throw new Error("Missing #buildLogger. Call `setBuildLogger`"); } return this.#buildLogger; } // In memory cache is up front, handles promise de-duping from input (this does not use getHash) // Note: output cache is also in play below (uses getHash) getInMemoryCacheKey() { let opts = Util.getSortedObject(this.options); opts.__originalSrc = this.src; if(this.isRemoteUrl) { opts.sourceUrl = this.src; // the source url } else if(Buffer.isBuffer(this.src)) { opts.sourceUrl = this.src.toString(); opts.__originalSize = this.src.length; } else { // Important: do not cache this opts.__originalSize = fs.statSync(this.src).size; } return JSON.stringify(opts, function(key, value) { // allows `transform` functions to be truthy for in-memory key if (typeof value === "function") { return "<fn>" + (value.name || ""); } return value; }); } getFileContents(overrideLocalFilePath) { if(!overrideLocalFilePath && this.isRemoteUrl) { return false; } let src = overrideLocalFilePath || this.src; if(!this.#contents[src]) { // perf: check to make sure it’s not a string first if(typeof src !== "string" && Buffer.isBuffer(src)) { this.#contents[src] = src; } else { debugAssets("[11ty/eleventy-img] Reading %o", src); this.#contents[src] = fs.readFileSync(src); } } // Always <Buffer> return this.#contents[src]; } static getValidWidths(originalWidth, widths = [], allowUpscale = false, minimumThreshold = 1) { // replace any falsy values with the original width let valid = widths.map(width => !width || width === 'auto' ? originalWidth : width); // Convert strings to numbers, "400" (floats are not allowed in sharp) valid = valid.map(width => parseInt(width, 10)); // Replace any larger-than-original widths with the original width if upscaling is not allowed. // This ensures that if a larger width has been requested, we're at least providing the closest // non-upscaled image that we can. if (!allowUpscale) { let lastWidthWasBigEnough = true; // first one is always valid valid = valid.sort((a, b) => a - b).map(width => { if(width > originalWidth) { if(lastWidthWasBigEnough) { return originalWidth; } return -1; } lastWidthWasBigEnough = originalWidth > Math.floor(width * minimumThreshold); return width; }).filter(width => width > 0); } // Remove duplicates (e.g., if null happens to coincide with an explicit width // or a user passes in multiple duplicate values, or multiple larger-than-original // widths have resulted in the original width being included multiple times) valid = [...new Set(valid)]; // sort ascending return valid.sort((a, b) => a - b); } static getFormatsArray(formats, autoFormat, svgShortCircuit, isAnimated, hasTransparency) { if(formats && formats.length) { if(typeof formats === "string") { formats = formats.split(","); } formats = formats.map(format => { if(autoFormat) { if((!format || format === "auto")) { format = autoFormat; } } if(FORMAT_ALIASES[format]) { return FORMAT_ALIASES[format]; } return format; }); if(svgShortCircuit !== "size") { // svg must come first for possible short circuiting formats.sort((a, b) => { if(a === "svg") { return -1; } else if(b === "svg") { return 1; } return 0; }); } if(isAnimated) { let validAnimatedFormats = formats.filter(f => ANIMATED_TYPES.includes(f)); // override formats if a valid animated format is found, otherwise leave as-is if(validAnimatedFormats.length > 0) { debug("Filtering non-animated formats from output: from %o to %o", formats, validAnimatedFormats); formats = validAnimatedFormats; } else { debug("No animated output formats found for animated image, using original formats (may be a static image): %o", formats); } } if(hasTransparency) { let minimumValidTransparencyFormats = formats.filter(f => MINIMUM_TRANSPARENCY_TYPES.includes(f)); // override formats if a valid animated format is found, otherwise leave as-is if(minimumValidTransparencyFormats.length > 0) { let validTransparencyFormats = formats.filter(f => TRANSPARENCY_TYPES.includes(f)); debug("Filtering non-transparency-friendly formats from output: from %o to %o", formats, validTransparencyFormats); formats = validTransparencyFormats; } else { debug("At least one transparency-friendly output format of %o must be included if the source image has an alpha channel, skipping formatFiltering and using original formats: %o", MINIMUM_TRANSPARENCY_TYPES, formats); } } // Remove duplicates (e.g., if null happens to coincide with an explicit format // or a user passes in multiple duplicate values) formats = [...new Set(formats)]; return formats; } return []; } #transformRawFiles(files = []) { let byType = {}; for(let file of files) { if(!byType[file.format]) { byType[file.format] = []; } byType[file.format].push(file); } for(let type in byType) { // sort by width, ascending (for `srcset`) byType[type].sort((a, b) => { return a.width - b.width; }); } let filterLargeRasterImages = this.options.svgShortCircuit === "size"; let svgEntry = byType.svg; let svgSize = svgEntry && svgEntry.length && svgEntry[0].size; if(filterLargeRasterImages && svgSize) { for(let type of Object.keys(byType)) { if(type === "svg") { continue; } let svgAdded = false; let originalFormatKept = false; byType[type] = byType[type].map(entry => { if(entry.size > svgSize) { if(!svgAdded) { svgAdded = true; // need at least one raster smaller than SVG to do this trick if(originalFormatKept) { return svgEntry[0]; } // all rasters are bigger return false; } return false; } originalFormatKept = true; return entry; }).filter(entry => entry); } } return byType; } #finalizeResults(results = {}) { // used when results are passed to generate HTML, we maintain some internal metadata about the options used. let imgAttributes = this.options.htmlOptions?.imgAttributes || {}; imgAttributes.src = this.src; Object.defineProperty(results, "eleventyImage", { enumerable: false, writable: false, value: { htmlOptions: { whitespaceMode: this.options.htmlOptions?.whitespaceMode, imgAttributes, pictureAttributes: this.options.htmlOptions?.pictureAttributes, fallback: this.options.htmlOptions?.fallback, }, } }); // renamed `return` to `returnType` to match Fetch API in v6.0.0-beta.3 if(this.options.returnType === "html" || this.options.return === "html") { return generateHTML(results); } return results; } getSharpOptionsForFormat(format) { if(format === "webp") { return this.options.sharpWebpOptions; } else if(format === "jpeg") { return this.options.sharpJpegOptions; } else if(format === "png") { return this.options.sharpPngOptions; } else if(format === "avif") { return this.options.sharpAvifOptions; } return {}; } async getInput() { // internal cache if(!this.#input) { if(this.isRemoteUrl) { // fetch remote image Buffer this.#input = this.assetCache.queue(); } else { // not actually a promise, this is sync this.#input = this.getFileContents(); } } return this.#input; } getHash() { if (this.#computedHash) { return this.#computedHash; } // debug("Creating hash for %o", this.src); let hashContents = []; if(existsCache.exists(this.src)) { let fileContents = this.getFileContents(); // If the file starts with whitespace or the '<' character, it might be SVG. // Otherwise, skip the expensive buffer.toString() call // (no point in unicode encoding a binary file) let fileContentsPrefix = fileContents?.slice(0, 1)?.toString()?.trim(); if (!fileContentsPrefix || fileContentsPrefix[0] == "<") { // remove all newlines for hashing for better cross-OS hash compatibility (Issue #122) let fileContentsStr = fileContents.toString(); let firstFour = fileContentsStr.trim().slice(0, 5); if(firstFour === "<svg " || firstFour === "<?xml") { fileContents = fileContentsStr.replace(/\r|\n/g, ''); } } hashContents.push(fileContents); } else { // probably a remote URL hashContents.push(this.src); // `useCacheValidityInHash` was removed in v6.0.0, but we’ll keep this as part of the hash to maintain consistent hashes across versions if(this.isRemoteUrl && this.assetCache && this.cacheOptions) { hashContents.push(`ValidCache:true`); } } // We ignore all keys not relevant to the file processing/output (including `widths`, which is a suffix added to the filename) // e.g. `widths: [300]` and `widths: [300, 600]`, with all else being equal the 300px output of each should have the same hash let keysToKeep = [ "sharpOptions", "sharpWebpOptions", "sharpPngOptions", "sharpJpegOptions", "sharpAvifOptions" ].sort(); let hashObject = {}; // The code currently assumes are keysToKeep are Object literals (see Util.getSortedObject) for(let key of keysToKeep) { if(this.options[key]) { hashObject[key] = Util.getSortedObject(this.options[key]); } } hashContents.push(JSON.stringify(hashObject)); let base64hash = createHashSync(...hashContents); let truncated = base64hash.substring(0, this.options.hashLength); this.#computedHash = truncated; return truncated; } getStat(outputFormat, width, height) { let url; let outputFilename; if(this.options.urlFormat && typeof this.options.urlFormat === "function") { let hash; if(!this.options.statsOnly) { hash = this.getHash(); } url = this.options.urlFormat({ hash, src: this.src, width, format: outputFormat, }, this.options); } else { let hash = this.getHash(); outputFilename = ImagePath.getFilename(hash, this.src, width, outputFormat, this.options); if(Util.isFullUrl(this.options.urlPath)) { url = new URL(outputFilename, this.options.urlPath).toString(); } else { url = ImagePath.convertFilePathToUrl(this.options.urlPath, outputFilename); } } let statEntry = { format: outputFormat, width: width, height: height, url: url, sourceType: MIME_TYPES[outputFormat], srcset: `${url} ${width}w`, // Not available in stats* functions below // size // only after processing }; if(outputFilename) { statEntry.filename = outputFilename; // optional statEntry.outputPath = path.join(this.options.outputDir, outputFilename); // optional } return statEntry; } // https://jdhao.github.io/2019/07/31/image_rotation_exif_info/ // Orientations 5 to 8 mean image is rotated ±90º (width/height are flipped) needsRotation(orientation) { // Sharp's metadata API exposes undefined EXIF orientations >8 as 1 (normal) but check anyways return orientation >= 5 && orientation <= 8; } isAnimated(metadata) { // sharp options have animated image support enabled if(!this.options?.sharpOptions?.animated) { return false; } let isAnimationFriendlyFormat = ANIMATED_TYPES.includes(metadata.format); if(!isAnimationFriendlyFormat) { return false; } if(metadata?.pages) { // input has multiple pages: https://sharp.pixelplumbing.com/api-input#metadata // this is *unknown* when not called from `resize` (limited metadata available) return metadata?.pages > 1; } // Best guess return isAnimationFriendlyFormat; } getEntryFormat(metadata) { return metadata.format || this.options.overrideInputFormat; } // metadata so far: width, height, format // src is used to calculate the output file names getFullStats(metadata) { let results = []; let isImageAnimated = this.isAnimated(metadata) && Array.isArray(this.options.formatFiltering) && this.options.formatFiltering.includes("animated"); let hasAlpha = metadata.hasAlpha && Array.isArray(this.options.formatFiltering) && this.options.formatFiltering.includes("transparent"); let entryFormat = this.getEntryFormat(metadata); let outputFormats = Image.getFormatsArray(this.options.formats, entryFormat, this.options.svgShortCircuit, isImageAnimated, hasAlpha); if (this.needsRotation(metadata.orientation)) { [metadata.height, metadata.width] = [metadata.width, metadata.height]; } if(metadata.pageHeight) { // When the { animated: true } option is provided to sharp, animated // image formats like gifs or webp will have an inaccurate `height` value // in their metadata which is actually the height of every single frame added together. // In these cases, the metadata will contain an additional `pageHeight` property which // is the height that the image should be displayed at. metadata.height = metadata.pageHeight; } for(let outputFormat of outputFormats) { if(!outputFormat || outputFormat === "auto") { throw new Error("When using statsSync or statsByDimensionsSync, `formats: [null | 'auto']` to use the native image format is not supported."); } if(outputFormat === "svg") { if(entryFormat === "svg") { let svgStats = this.getStat("svg", metadata.width, metadata.height); // SVG metadata.size is only available with Buffer input (remote urls) if(metadata.size) { // Note this is unfair for comparison with raster formats because its uncompressed (no GZIP, etc) svgStats.size = metadata.size; } results.push(svgStats); if(this.options.svgShortCircuit === true) { break; } else { continue; } } else { debug("Skipping SVG output for %o: received raster input.", this.src); continue; } } else { // not outputting SVG (might still be SVG input though!) let widths = Image.getValidWidths(metadata.width, this.options.widths, metadata.format === "svg" && this.options.svgAllowUpscale, this.options.minimumThreshold); for(let width of widths) { let height = Image.getAspectRatioHeight(metadata, width); results.push(this.getStat(outputFormat, width, height)); } } } return this.#transformRawFiles(results); } static getDimensionsFromSharp(sharpInstance, stat) { let dims = {}; if(sharpInstance.options.width > -1) { dims.width = sharpInstance.options.width; dims.resized = true; } if(sharpInstance.options.height > -1) { dims.height = sharpInstance.options.height; dims.resized = true; } if(dims.width || dims.height) { if(!dims.width) { dims.width = Image.getAspectRatioWidth(stat, dims.height); } if(!dims.height) { dims.height = Image.getAspectRatioHeight(stat, dims.width); } } return dims; } static getAspectRatioWidth(originalDimensions, newHeight) { return Math.floor(newHeight * originalDimensions.width / originalDimensions.height); } static getAspectRatioHeight(originalDimensions, newWidth) { // Warning: if this is a guess via statsByDimensionsSync and that guess is wrong // The aspect ratio will be wrong and any height/widths returned will be wrong! return Math.floor(newWidth * originalDimensions.height / originalDimensions.width); } getOutputSize(contents, filePath) { if(contents) { if(this.options.svgCompressionSize === "br") { return brotliSize(contents); } if("length" in contents) { return contents.length; } } // fallback to looking on local file system if(!filePath) { throw new Error("`filePath` expected."); } return fs.statSync(filePath).size; } isOutputCached(targetFile, sourceInput) { if(!this.options.useCache) { return false; } // last cache was a miss, so we must write to disk if(this.assetCache && !this.assetCache.wasLastFetchCacheHit()) { return false; } if(!diskCache.isCached(targetFile, sourceInput, !Util.isRequested(this.options.generatedVia))) { return false; } return true; } // src should be a file path to an image or a buffer async resize(input) { let sharpInputImage = sharp(input, Object.assign({ // Deprecated by sharp, use `failOn` option instead // https://github.com/lovell/sharp/blob/1533bf995acda779313fc178d2b9d46791349961/lib/index.d.ts#L915 failOnError: false, }, this.options.sharpOptions)); // Must find the image format from the metadata // File extensions lie or may not be present in the src url! let sharpMetadata = await sharpInputImage.metadata(); let outputFilePromises = []; let fullStats = this.getFullStats(sharpMetadata); for(let outputFormat in fullStats) { for(let stat of fullStats[outputFormat]) { if(this.isOutputCached(stat.outputPath, input)) { // Cached images already exist in output let outputFileContents; if(this.options.dryRun || outputFormat === "svg" && this.options.svgCompressionSize === "br") { outputFileContents = this.getFileContents(stat.outputPath); } if(this.options.dryRun) { stat.buffer = outputFileContents; } stat.size = this.getOutputSize(outputFileContents, stat.outputPath); outputFilePromises.push(Promise.resolve(stat)); continue; } let sharpInstance = sharpInputImage.clone(); let transform = this.options.transform; let isTransformResize = false; if(transform) { if(typeof transform !== "function") { throw new Error("Expected `function` type in `transform` option. Received: " + transform); } await transform(sharpInstance); // Resized in a transform (maybe for a crop) let dims = Image.getDimensionsFromSharp(sharpInstance, stat); if(dims.resized) { isTransformResize = true; // Overwrite current `stat` object with new sizes and file names stat = this.getStat(stat.format, dims.width, dims.height); } } // https://github.com/11ty/eleventy-img/issues/244 sharpInstance.keepIccProfile(); // Output images do not include orientation metadata (https://github.com/11ty/eleventy-img/issues/52) // Use sharp.rotate to bake orientation into the image (https://github.com/lovell/sharp/blob/v0.32.6/docs/api-operation.md#rotate): // > If no angle is provided, it is determined from the EXIF data. Mirroring is supported and may infer the use of a flip operation. // > The use of rotate without an angle will remove the EXIF Orientation tag, if any. if(this.options.fixOrientation || this.needsRotation(sharpMetadata.orientation)) { sharpInstance.rotate(); } if(!isTransformResize) { if(stat.width < sharpMetadata.width || (this.options.svgAllowUpscale && sharpMetadata.format === "svg")) { let resizeOptions = { width: stat.width }; if(sharpMetadata.format !== "svg" || !this.options.svgAllowUpscale) { resizeOptions.withoutEnlargement = true; } sharpInstance.resize(resizeOptions); } } // Format hooks take priority over Sharp processing. // format hooks are only used for SVG out of the box if(this.options.formatHooks && this.options.formatHooks[outputFormat]) { let hookResult = await this.options.formatHooks[outputFormat].call(stat, sharpInstance); if(hookResult) { stat.size = this.getOutputSize(hookResult); if(this.options.dryRun) { stat.buffer = Buffer.from(hookResult); outputFilePromises.push(Promise.resolve(stat)); } else { this.directoryManager.createFromFile(stat.outputPath); debugAssets("[11ty/eleventy-img] Writing %o", stat.outputPath); outputFilePromises.push(fsp.writeFile(stat.outputPath, hookResult).then(() => stat)); } } } else { // not a format hook let sharpFormatOptions = this.getSharpOptionsForFormat(outputFormat); let hasFormatOptions = Object.keys(sharpFormatOptions).length > 0; if(hasFormatOptions || outputFormat && sharpMetadata.format !== outputFormat) { // https://github.com/lovell/sharp/issues/3680 // Fix heic regression in sharp 0.33 if(outputFormat === "heic" && !sharpFormatOptions.compression) { sharpFormatOptions.compression = "av1"; } sharpInstance.toFormat(outputFormat, sharpFormatOptions); } if(!this.options.dryRun && stat.outputPath) { // Should never write when dryRun is true this.directoryManager.createFromFile(stat.outputPath); debugAssets("[11ty/eleventy-img] Writing %o", stat.outputPath); outputFilePromises.push( sharpInstance.toFile(stat.outputPath) .then(info => { stat.size = info.size; return stat; }) ); } else { outputFilePromises.push(sharpInstance.toBuffer({ resolveWithObject: true }).then(({ data, info }) => { stat.buffer = data; stat.size = info.size; return stat; })); } } if(stat.outputPath) { if(this.options.dryRun) { debug( "Generated %o", stat.url ); } else { debug( "Wrote %o", stat.outputPath ); } } } } return Promise.all(outputFilePromises).then(files => this.#finalizeResults(this.#transformRawFiles(files))); } async getStatsOnly() { if(typeof this.src !== "string" || !this.options.statsOnly) { return; } let input; if(Util.isRemoteUrl(this.src)) { if(this.rawOptions.remoteImageMetadata?.width && this.rawOptions.remoteImageMetadata?.height) { return this.getFullStats({ width: this.rawOptions.remoteImageMetadata.width, height: this.rawOptions.remoteImageMetadata.height, format: this.rawOptions.remoteImageMetadata.format, // only required if you want to use the "auto" format guess: true, }); } // Fetch remote image to operate on it // `remoteImageMetadata` is no longer required for statsOnly on remote images input = await this.getInput(); } // Local images try { // Related to https://github.com/11ty/eleventy-img/issues/295 let { width, height, type } = getImageSize(input || this.src); return this.getFullStats({ width, height, format: type // only required if you want to use the "auto" format }); } catch(e) { throw new Error(`Eleventy Image error (statsOnly): \`image-size\` on "${this.src}" failed. Original error: ${e.message}`); } } // returns raw Promise queue() { if(!this.#queue) { return Promise.reject(new Error("Missing #queue.")); } if(this.#queuePromise) { return this.#queuePromise; } debug("Processing %o (in-memory cache miss), options: %o", this.src, this.options); this.#queuePromise = this.#queue.add(async () => { try { if(typeof this.src === "string" && this.options.statsOnly) { return this.getStatsOnly(); } this.buildLogger.log(`Processing ${this.buildLogger.getFriendlyImageSource(this.src)}`, this.options); let input = await this.getInput(); return this.resize(input); } catch(e) { this.buildLogger.error(`Error: ${e.message} (via ${this.buildLogger.getFriendlyImageSource(this.src)})`, this.options); if(this.options.failOnError) { throw e; } } }); return this.#queuePromise; } // Factory to return from cache if available static create(src, options = {}) { let img = new Image(src, options); // use resolved options for this if(!img.options.useCache) { return img; } let key = img.getInMemoryCacheKey(); let cached = memCache.get(key, !options.transformOnRequest && !Util.isRequested(options.generatedVia)); if(cached) { return cached; } memCache.add(key, img); return img; } /* `statsSync` doesn’t generate any files, but will tell you where * the asynchronously generated files will end up! This is useful * in synchronous-only template environments where you need the * image URLs synchronously but can’t rely on the files being in * the correct location yet. * * `options.dryRun` is still asynchronous but also doesn’t generate * any files. */ statsSync() { if(this.isRemoteUrl) { throw new Error("`statsSync` is not supported with remote sources. Use `statsByDimensionsSync(src, width, height, options)` instead."); } let dimensions = getImageSize(this.src); return this.getFullStats({ width: dimensions.width, height: dimensions.height, format: dimensions.type, }); } static statsSync(src, opts) { if(typeof src === "string" && Util.isRemoteUrl(src)) { throw new Error("`statsSync` is not supported with remote sources. Use `statsByDimensionsSync(src, width, height, options)` instead."); } let img = Image.create(src, opts); return img.statsSync(); } statsByDimensionsSync(width, height) { let dimensions = { width, height, guess: true }; return this.getFullStats(dimensions); } static statsByDimensionsSync(src, width, height, opts) { let img = Image.create(src, opts); return img.statsByDimensionsSync(width, height); } } module.exports = Image;