UNPKG

@r1tsu/payload

Version:

239 lines (238 loc) 11.1 kB
import fileType from 'file-type'; const { fromBuffer } = fileType; import fs from 'fs'; import sanitize from 'sanitize-filename'; import { isNumber } from '../utilities/isNumber.js'; import fileExists from './fileExists.js'; /** * Sanitize the image name and extract the extension from the source image * * @param sourceImage - the source image * @returns the sanitized name and extension */ const getSanitizedImageData = (sourceImage)=>{ const extension = sourceImage.split('.').pop(); const name = sanitize(sourceImage.substring(0, sourceImage.lastIndexOf('.')) || sourceImage); return { name, ext: extension }; }; /** * Create a new image name based on the output image name, the dimensions and * the extension. * * Ignore the fact that duplicate names could happen if the there is one * size with `width AND height` and one with only `height OR width`. Because * space is expensive, we will reuse the same image for both sizes. * * @param outputImageName - the sanitized image name * @param bufferInfo - the buffer info * @param extension - the extension to use * @returns the new image name that is not taken */ const createImageName = (outputImageName, { height, width }, extension)=>`${outputImageName}-${width}x${height}.${extension}`; /** * Create the result object for the image resize operation based on the * provided parameters. If the name is not provided, an empty result object * is returned. * * @param name - the name of the image * @param filename - the filename of the image * @param width - the width of the image * @param height - the height of the image * @param filesize - the filesize of the image * @param mimeType - the mime type of the image * @param sizesToSave - the sizes to save * @returns the result object */ const createResult = (name, filename = null, width = null, height = null, filesize = null, mimeType = null, sizesToSave = [])=>({ sizeData: { [name]: { filename, filesize, height, mimeType, width } }, sizesToSave }); /** * Check if the image needs to be resized according to the requested dimensions * and the original image size. If the resize options withoutEnlargement or withoutReduction are provided, * the image will be resized regardless of the requested dimensions, given that the * width or height to be resized is provided. * * @param resizeConfig - object containing the requested dimensions and resize options * @param original - the original image size * @returns true if resizing is not needed, false otherwise */ const preventResize = ({ height: desiredHeight, width: desiredWidth, withoutEnlargement, withoutReduction }, original)=>{ // default is to allow reduction if (withoutReduction !== undefined) { return false // needs resize ; } // default is to prevent enlargement if (withoutEnlargement !== undefined) { return false // needs resize ; } const isWidthOrHeightNotDefined = !desiredHeight || !desiredWidth; if (isWidthOrHeightNotDefined) { // If width and height are not defined, it means there is a format conversion // and the image needs to be "resized" (transformed). return false // needs resize ; } const hasInsufficientWidth = desiredWidth > original.width; const hasInsufficientHeight = desiredHeight > original.height; if (hasInsufficientWidth && hasInsufficientHeight) { // doesn't need resize - prevent enlargement. This should only happen if both width and height are insufficient. // if only one dimension is insufficient and the other is sufficient, resizing needs to happen, as the image // should be resized to the sufficient dimension. return true // do not create a new size ; } return false // needs resize ; }; /** * Check if the image should be passed directly to sharp without payload adjusting properties. * * @param resizeConfig - object containing the requested dimensions and resize options * @param original - the original image size * @returns true if the image should passed directly to sharp */ const applyPayloadAdjustments = ({ fit, height, width, withoutEnlargement, withoutReduction }, original)=>{ if (fit === 'contain' || fit === 'inside') return false; if (!isNumber(height) && !isNumber(width)) return false; const targetAspectRatio = width / height; const originalAspectRatio = original.width / original.height; if (originalAspectRatio === targetAspectRatio) return false; const skipEnlargement = withoutEnlargement && (original.height < height || original.width < width); const skipReduction = withoutReduction && (original.height > height || original.width > width); if (skipEnlargement || skipReduction) return false; return true; }; /** * Sanitize the resize config. If the resize config has the `withoutReduction` * property set to true, the `fit` and `position` properties will be set to `contain` * and `top left` respectively. * * @param resizeConfig - the resize config * @returns a sanitized resize config */ const sanitizeResizeConfig = (resizeConfig)=>{ if (resizeConfig.withoutReduction) { return { ...resizeConfig, // Why fit `contain` should also be set to https://github.com/lovell/sharp/issues/3595 fit: resizeConfig?.fit || 'contain', position: resizeConfig?.position || 'left top' }; } return resizeConfig; }; /** * For the provided image sizes, handle the resizing and the transforms * (format, trim, etc.) of each requested image size and return the result object. * This only handles the image sizes. The transforms of the original image * are handled in {@link ./generateFileData.ts}. * * The image will be resized according to the provided * resize config. If no image sizes are requested, the resolved data will be empty. * For every image that dos not need to be resized, an result object with `null` * parameters will be returned. * * @param resizeConfig - the resize config * @returns the result of the resize operation(s) */ export default async function resizeAndTransformImageSizes({ config, dimensions, file, mimeType, req, savedFilename, sharp, staticPath }) { const { imageSizes } = config.upload; // Noting to resize here so return as early as possible if (!imageSizes) return { sizeData: {}, sizesToSave: [] }; const sharpBase = sharp(file.tempFilePath || file.data).rotate() // pass rotate() to auto-rotate based on EXIF data. https://github.com/payloadcms/payload/pull/3081 ; const results = await Promise.all(imageSizes.map(async (imageResizeConfig)=>{ imageResizeConfig = sanitizeResizeConfig(imageResizeConfig); // This checks if a resize should happen. If not, the resized image will be // skipped COMPLETELY and thus will not be included in the resulting images. // All further format/trim options will thus be skipped as well. if (preventResize(imageResizeConfig, dimensions)) { return createResult(imageResizeConfig.name); } const imageToResize = sharpBase.clone(); let resized = imageToResize; if (req.query?.uploadEdits?.focalPoint && applyPayloadAdjustments(imageResizeConfig, dimensions)) { const { height: resizeHeight, width: resizeWidth } = imageResizeConfig; const resizeAspectRatio = resizeWidth / resizeHeight; const originalAspectRatio = dimensions.width / dimensions.height; const prioritizeHeight = resizeAspectRatio < originalAspectRatio; // Scale the image up or down to fit the resize dimensions const scaledImage = imageToResize.resize({ height: prioritizeHeight ? resizeHeight : null, width: prioritizeHeight ? null : resizeWidth }); const { info: scaledImageInfo } = await scaledImage.toBuffer({ resolveWithObject: true }); // Focal point adjustments const focalPoint = { x: isNumber(req.query.uploadEdits.focalPoint?.x) ? req.query.uploadEdits.focalPoint.x : 50, y: isNumber(req.query.uploadEdits.focalPoint?.y) ? req.query.uploadEdits.focalPoint.y : 50 }; const safeResizeWidth = resizeWidth ?? scaledImageInfo.width; const maxOffsetX = scaledImageInfo.width - safeResizeWidth; const leftFocalEdge = Math.round(scaledImageInfo.width * (focalPoint.x / 100) - safeResizeWidth / 2); const safeOffsetX = Math.min(Math.max(0, leftFocalEdge), maxOffsetX); const safeResizeHeight = resizeHeight ?? scaledImageInfo.height; const maxOffsetY = scaledImageInfo.height - safeResizeHeight; const topFocalEdge = Math.round(scaledImageInfo.height * (focalPoint.y / 100) - safeResizeHeight / 2); const safeOffsetY = Math.min(Math.max(0, topFocalEdge), maxOffsetY); // extract the focal area from the scaled image resized = scaledImage.extract({ height: safeResizeHeight, left: safeOffsetX, top: safeOffsetY, width: safeResizeWidth }); } else { resized = imageToResize.resize(imageResizeConfig); } if (imageResizeConfig.formatOptions) { resized = resized.toFormat(imageResizeConfig.formatOptions.format, imageResizeConfig.formatOptions.options); } if (imageResizeConfig.trimOptions) { resized = resized.trim(imageResizeConfig.trimOptions); } const { data: bufferData, info: bufferInfo } = await resized.toBuffer({ resolveWithObject: true }); const sanitizedImage = getSanitizedImageData(savedFilename); req.payloadUploadSizes[imageResizeConfig.name] = bufferData; const mimeInfo = await fromBuffer(bufferData); const imageNameWithDimensions = createImageName(sanitizedImage.name, bufferInfo, mimeInfo?.ext || sanitizedImage.ext); const imagePath = `${staticPath}/${imageNameWithDimensions}`; if (await fileExists(imagePath)) { try { fs.unlinkSync(imagePath); } catch { // Ignore unlink errors } } const { height, size, width } = bufferInfo; return createResult(imageResizeConfig.name, imageNameWithDimensions, width, height, size, mimeInfo?.mime || mimeType, [ { buffer: bufferData, path: imagePath } ]); })); return results.reduce((acc, result)=>{ Object.assign(acc.sizeData, result.sizeData); acc.sizesToSave.push(...result.sizesToSave); return acc; }, { sizeData: {}, sizesToSave: [] }); } //# sourceMappingURL=imageResizer.js.map