@r1tsu/payload
Version:
239 lines (238 loc) • 11.1 kB
JavaScript
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