imagetools-core
Version:
604 lines (574 loc) • 19.2 kB
JavaScript
const METADATA = Symbol('image metadata');
function setMetadata(image, key, value) {
if (image[METADATA]) {
image[METADATA][key] = value;
}
}
function getMetadata(image, key) {
var _a;
return (_a = image[METADATA]) === null || _a === void 0 ? void 0 : _a[key];
}
const getBackground = ({ background }, image) => {
if (typeof background !== 'string' || !background.length)
return;
image[METADATA].backgroundDirective = background;
return background;
};
const blur = (config) => {
let blur = undefined;
blur = config.blur ? parseFloat(config.blur) : undefined;
blur || (blur = config.blur === 'true');
blur || (blur = config.blur === '');
if (!blur)
return;
return function blurTransform(image) {
image[METADATA].blur = blur;
return image.blur(blur);
};
};
const FORMAT_TO_EFFORT_RANGE = {
avif: [0, 9],
gif: [1, 10],
heif: [0, 9],
jxl: [3, 9],
png: [1, 10],
webp: [0, 6]
};
function parseEffort(effort, format) {
var _a, _b;
if (effort === 'min') {
return (_a = FORMAT_TO_EFFORT_RANGE[format]) === null || _a === void 0 ? void 0 : _a[0];
}
else if (effort === 'max') {
return (_b = FORMAT_TO_EFFORT_RANGE[format]) === null || _b === void 0 ? void 0 : _b[1];
}
return parseInt(effort);
}
const getEffort = ({ effort: _effort }, image) => {
var _a;
if (!_effort)
return;
const format = ((_a = getMetadata(image, 'format')) !== null && _a !== void 0 ? _a : '');
const effort = parseEffort(_effort, format);
if (!Number.isInteger(effort))
return;
setMetadata(image, 'effort', effort);
return effort;
};
const fitValues = ['cover', 'contain', 'fill', 'inside', 'outside'];
const getFit = (config, image) => {
let fit = undefined;
if (config.fit && fitValues.includes(config.fit)) {
fit = config.fit;
}
else {
fit = Object.keys(config).find((k) => fitValues.includes(k) && config[k] === '');
}
if (!fit)
return;
image[METADATA].fit = fit;
return fit;
};
const flatten = (config) => {
if (config.flatten !== '' && config.flatten !== 'true')
return;
return function flattenTransform(image) {
image[METADATA].flatten = true;
return image.flatten({
background: getBackground(config, image)
});
};
};
const flip = ({ flip }) => {
if (flip !== '' && flip !== 'true')
return;
return function flipTransform(image) {
image[METADATA].flip = true;
return image.flip();
};
};
const flop = ({ flop }) => {
if (flop !== '' && flop !== 'true')
return;
return function flopTransform(image) {
image[METADATA].flop = true;
return image.flop();
};
};
const getQuality = ({ quality: _quality }, image) => {
const quality = _quality && parseInt(_quality);
if (!quality)
return;
image[METADATA].quality = quality;
return quality;
};
const getProgressive = ({ progressive }, image) => {
if (progressive !== '' && progressive !== 'true')
return;
image[METADATA].progressive = true;
return true;
};
const getLossless = ({ lossless }, image) => {
if (lossless !== '' && lossless !== 'true')
return;
image[METADATA].lossless = true;
return true;
};
const format = (config) => {
let format;
if (!config.format) {
return;
}
else {
format = config.format;
}
return function formatTransform(image) {
image[METADATA].format = format;
return image.toFormat(format, {
compression: format == 'heif' ? 'av1' : undefined,
effort: getEffort(config, image),
lossless: getLossless(config, image),
// @ts-expect-error not every image type supports progressive
progressive: getProgressive(config, image),
quality: getQuality(config, image)
});
};
};
const grayscale = ({ grayscale }) => {
if (grayscale !== '' && grayscale !== 'true')
return;
return function grayscaleTransform(image) {
image[METADATA].grayscale = true;
return image.grayscale();
};
};
const hsb = (config) => {
const hue = config.hue && parseInt(config.hue);
const saturation = config.saturation && parseFloat(config.saturation);
const brightness = config.brightness && parseFloat(config.brightness);
if (!hue && !saturation && !brightness)
return;
return function hsbTransform(image) {
image[METADATA].hue = hue;
image[METADATA].saturation = saturation;
image[METADATA].brightness = brightness;
return image.modulate({
hue: hue || 0,
saturation: saturation || 1,
brightness: brightness || 1
});
};
};
const invert = ({ invert }) => {
if (invert !== '' && invert !== 'true')
return;
return function invertTransform(image) {
image[METADATA].invert = true;
return image.negate();
};
};
const kernelValues = ['nearest', 'cubic', 'mitchell', 'lanczos2', 'lanczos3'];
const getKernel = ({ kernel }, image) => {
if (kernel && kernelValues.includes(kernel)) {
image[METADATA].kernel = kernel;
return kernel;
}
};
const median = (config) => {
const median = config.median ? parseInt(config.median) : undefined;
if (!median)
return;
return function medianTransform(image) {
image[METADATA].median = median;
return image.median(median);
};
};
const normalize = ({ normalize }) => {
if (normalize !== '' && normalize !== 'true')
return;
return function normalizeTransform(image) {
image[METADATA].normalize = true;
return image.normalize();
};
};
const positionValues = [
'top',
'right top',
'right',
'right bottom',
'bottom',
'left bottom',
'left',
'left top',
'north',
'northeast',
'east',
'southeast',
'south',
'southwest',
'west',
'northwest',
'center',
'centre',
'entropy',
'attention'
];
const positionShorthands = [
'top',
'right top',
'right',
'right bottom',
'bottom',
'left bottom',
'left',
'left top'
];
const getPosition = (config, image) => {
let position = undefined;
if (config.position && positionValues.includes(config.position)) {
position = config.position;
}
else {
position = Object.keys(config).find((k) => positionShorthands.includes(k) && config[k] === '');
}
if (!position)
return;
image[METADATA].position = position;
return position;
};
/**
* This function parses a user provided aspect-ratio string into a float.
* Valid syntaxes are `16:9` or `1.777`
* @param aspect
* @returns
*/
function parseAspect(aspect) {
const parts = aspect.split(':');
let aspectRatio;
if (parts.length === 1) {
// the string was a float
aspectRatio = parseFloat(parts[0]);
}
else if (parts.length === 2) {
// the string was a colon delimited aspect ratio
const [width, height] = parts.map((str) => parseInt(str));
if (!width || !height)
return undefined;
aspectRatio = width / height;
}
if (!aspectRatio || aspectRatio <= 0)
return undefined;
return aspectRatio;
}
const resize = (config, context) => {
const width = parseInt(config.w || '');
const height = parseInt(config.h || '');
const aspect = parseAspect(config.aspect || '');
const allowUpscale = config.allowUpscale === '' || config.allowUpscale === 'true';
const basePixels = parseInt(config.basePixels || '');
if (!width && !height && !aspect)
return;
return function resizeTransform(image) {
const fit = getFit(config, image);
// calculate finalWidth & finalHeight
const originalWidth = image[METADATA].width;
const originalHeight = image[METADATA].height;
const originalAspect = originalWidth / originalHeight;
let finalWidth = width, finalHeight = height, finalAspect = aspect;
if (aspect && !width && !height) {
// only aspect was given, need to calculate which dimension to crop
if (aspect > originalAspect) {
finalHeight = originalWidth / aspect;
finalWidth = originalWidth;
}
else {
finalHeight = originalHeight;
finalWidth = originalHeight * aspect;
}
}
else if (width && height) {
// width & height BOTH given, need to look at fit
switch (fit) {
case 'inside':
if (width / height < originalAspect) {
finalHeight = width / originalAspect;
}
else {
finalWidth = height * originalAspect;
}
break;
case 'outside':
if (width / height > originalAspect) {
finalHeight = width / originalAspect;
}
else {
finalWidth = height * originalAspect;
}
break;
}
finalAspect = finalWidth / finalHeight;
}
else if (!height) {
// only width was provided, need to calculate height
finalAspect = aspect || originalAspect;
finalHeight = width / finalAspect;
}
else if (!width) {
// only height was provided, need to calculate width
finalAspect = aspect || originalAspect;
finalWidth = height * finalAspect;
}
if (!allowUpscale && (finalHeight > originalHeight || finalWidth > originalWidth)) {
finalHeight = originalHeight;
finalWidth = originalWidth;
finalAspect = originalAspect;
if (context.manualSearchParams.has('w') || context.manualSearchParams.has('h')) {
context.logger.info('allowUpscale not enabled. Image width, height and aspect ratio reverted to original values');
}
}
finalWidth = Math.round(finalWidth);
finalHeight = Math.round(finalHeight);
image[METADATA].height = finalHeight;
image[METADATA].width = finalWidth;
image[METADATA].aspect = finalAspect;
image[METADATA].allowUpscale = allowUpscale;
image[METADATA].pixelDensityDescriptor = basePixels > 0 ? finalWidth / basePixels + 'x' : undefined;
return image.resize({
width: finalWidth || undefined,
height: finalHeight || undefined,
withoutEnlargement: !allowUpscale,
fit,
position: getPosition(config, image),
kernel: getKernel(config, image),
background: getBackground(config, image)
});
};
};
const rotate = (config) => {
const rotate = config.rotate && parseInt(config.rotate);
if (!rotate)
return;
return function rotateTransform(image) {
image[METADATA].rotate = rotate;
return image.rotate(rotate, {
background: getBackground(config, image)
});
};
};
const tint = ({ tint }) => {
if (typeof tint !== 'string' || !tint.length)
return;
return function tintTransform(image) {
image[METADATA].tint = '#' + tint;
return image.tint('#' + tint);
};
};
const builtins = [
blur,
flatten,
flip,
flop,
format,
grayscale,
hsb,
invert,
median,
normalize,
resize,
rotate,
tint
];
const urlFormat = () => (metadatas) => {
const urls = metadatas.map((metadata) => metadata.src);
return urls.length == 1 ? urls[0] : urls;
};
const srcsetFormat = () => metadatasToSourceset;
const metadataFormat = (whitelist) => (metadatas) => {
const result = whitelist
? metadatas.map((cfg) => Object.fromEntries(Object.entries(cfg).filter(([k]) => whitelist.includes(k))))
: metadatas;
result.forEach((m) => delete m.image);
return result.length === 1 ? result[0] : result;
};
const metadatasToSourceset = (metadatas) => metadatas
.map((meta) => {
const density = meta.pixelDensityDescriptor;
return density ? `${meta.src} ${density}` : `${meta.src} ${meta.width}w`;
})
.join(', ');
/** normalizes the format for use in mime-type */
const getFormat = (m) => {
if (!m.format)
throw new Error(`Could not determine image format`);
return m.format.replace('jpg', 'jpeg');
};
const imgFormat = () => (metadatas) => {
let largestImage;
let largestImageSize = 0;
for (let i = 0; i < metadatas.length; i++) {
const m = metadatas[i];
if (m.width > largestImageSize) {
largestImage = m;
largestImageSize = m.width;
}
}
const result = {
src: largestImage === null || largestImage === void 0 ? void 0 : largestImage.src,
w: largestImage === null || largestImage === void 0 ? void 0 : largestImage.width,
h: largestImage === null || largestImage === void 0 ? void 0 : largestImage.height
};
if (metadatas.length >= 2) {
result.srcset = metadatasToSourceset(metadatas);
}
return result;
};
/** fallback format should be specified last */
const pictureFormat = () => (metadatas) => {
const fallbackFormat = [...new Set(metadatas.map((m) => getFormat(m)))].pop();
let largestFallback;
let largestFallbackSize = 0;
let fallbackFormatCount = 0;
for (let i = 0; i < metadatas.length; i++) {
const m = metadatas[i];
if (getFormat(m) === fallbackFormat) {
fallbackFormatCount++;
if (m.width > largestFallbackSize) {
largestFallback = m;
largestFallbackSize = m.width;
}
}
}
const sourceMetadatas = {};
for (let i = 0; i < metadatas.length; i++) {
const m = metadatas[i];
const f = getFormat(m);
// we don't need to create a source tag for the fallback format if there is
// only a single image in that format
if (f === fallbackFormat && fallbackFormatCount < 2) {
continue;
}
if (sourceMetadatas[f]) {
sourceMetadatas[f].push(m);
}
else {
sourceMetadatas[f] = [m];
}
}
const sources = {};
for (const [key, value] of Object.entries(sourceMetadatas)) {
sources[key] = metadatasToSourceset(value);
}
const result = {
sources,
// the fallback should be the largest image in the fallback format
// we assume users should never upsize an image because that is just wasted
// bytes since the browser can upsize just as well
img: {
src: largestFallback === null || largestFallback === void 0 ? void 0 : largestFallback.src,
w: largestFallback === null || largestFallback === void 0 ? void 0 : largestFallback.width,
h: largestFallback === null || largestFallback === void 0 ? void 0 : largestFallback.height
}
};
return result;
};
const builtinOutputFormats = {
url: urlFormat,
srcset: srcsetFormat,
img: imgFormat,
picture: pictureFormat,
metadata: metadataFormat,
meta: metadataFormat
};
function parseURL(rawURL) {
return new URL(rawURL.replace(/#/g, '%23'), 'file://');
}
function extractEntries(searchParams) {
const entries = [];
for (const [key, value] of searchParams) {
const values = value.includes(':') ? [value] : value.split(';');
entries.push([key, values]);
}
return entries;
}
/**
* This function calculates the cartesian product of two or more arrays and is straight from stackoverflow ;)
* Should be replaced with something more legible but works for now.
*/
const cartesian = (...a) =>
// eslint-disable-next-line @typescript-eslint/no-explicit-any
a.reduce((a, b) => a.flatMap((d) => b.map((e) => [d, e].flat())));
/**
* This function builds up all possible combinations the given entries can be combined
* and returns it as an array of objects that can be given to a the transforms.
* @param entries The url parameter entries
* @returns An array of directive options
*/
function resolveConfigs(entries, outputFormats) {
// create a new array of entries for each argument
const singleArgumentEntries = entries
.filter(([k]) => !(k in outputFormats))
.map(([key, values]) => values.map((v) => [[key, v]]));
// do a cartesian product on all entries to get all combinations we need to produce
const combinations = singleArgumentEntries
// .filter(([key]) => !(key[0][0] in outputFormats))
.reduce((prev, cur) => (prev.length ? cartesian(prev, cur) : cur), []);
const metadataAddons = entries.filter(([k]) => k in outputFormats);
// and return as an array of objects
const out = combinations.map((options) => Object.fromEntries([...options, ...metadataAddons]));
return out.length ? out : [Object.fromEntries(metadataAddons)];
}
const consoleLogger = {
info(msg) {
console.info(msg);
},
warn(msg) {
console.warn(msg);
},
error(msg) {
console.error(msg);
}
};
function generateTransforms(config, factories, manualSearchParams, logger) {
if (logger === undefined) {
logger = consoleLogger;
}
const transforms = [];
const parametersUsed = new Set();
const context = {
useParam: (k) => parametersUsed.add(k),
manualSearchParams,
logger
};
for (const directive of factories) {
const transform = directive(config, context);
if (typeof transform === 'function')
transforms.push(transform);
}
return {
transforms,
parametersUsed
};
}
async function applyTransforms(transforms, image, removeMetadata = true) {
image[METADATA] = { ...(await image.metadata()) };
if (removeMetadata) {
// delete the private metadata
delete image[METADATA].exif;
delete image[METADATA].iptc;
delete image[METADATA].xmp;
delete image[METADATA].tifftagPhotoshop;
delete image[METADATA].icc;
}
else {
image.withMetadata();
}
for (const transform of transforms) {
image = await transform(image);
}
return {
image,
metadata: image[METADATA]
};
}
export { applyTransforms, blur, builtinOutputFormats, builtins, extractEntries, fitValues, flatten, flip, flop, format, generateTransforms, getBackground, getEffort, getFit, getKernel, getLossless, getMetadata, getPosition, getProgressive, getQuality, grayscale, hsb, imgFormat, invert, kernelValues, median, metadataFormat, normalize, parseURL, pictureFormat, positionShorthands, positionValues, resize, resolveConfigs, rotate, setMetadata, srcsetFormat, tint, urlFormat };
//# sourceMappingURL=index.js.map