import { htmlColorNames, type NamedColor } from '../utils/colornames.js';
///
export type InputFormat =
| 'heic'
| 'heif'
| 'avif'
| 'jpeg'
| 'jpg'
| 'png'
| 'tiff'
| 'webp'
| 'gif';
export type OutputFormatSupportsAlpha = 'avif' | 'png' | 'webp';
export type OutputFormat = OutputFormatSupportsAlpha | 'jpeg' | 'jpg';
export type ColorDefinition =
| NamedColor
| `#${string}`
| `rgb(${number}, ${number}, ${number})`
| `rgb(${number},${number},${number})`
| `rgba(${number}, ${number}, ${number}, ${number})`
| `rgba(${number},${number},${number},${number})`;
export type CropFit = 'cover' | 'contain' | 'fill' | 'inside' | 'outside';
export type CropPosition =
| 'top'
| 'right top'
| 'right'
| 'right bottom'
| 'bottom'
| 'left bottom'
| 'left'
| 'left top'
| 'north'
| 'northeast'
| 'east'
| 'southeast'
| 'south'
| 'southwest'
| 'west'
| 'northwest'
| 'center'
| 'centre'
| 'cover'
| 'entropy'
| 'attention';
export function isOutputFormat(value: string): value is OutputFormat {
return ['avif', 'jpeg', 'jpg', 'png', 'webp'].includes(value);
}
export function isOutputFormatSupportsAlpha(value: string): value is OutputFormatSupportsAlpha {
return ['avif', 'png', 'webp'].includes(value);
}
export function isAspectRatioString(value: string): value is `${number}:${number}` {
return /^\d*:\d*$/.test(value);
}
export function isColor(value: string): value is ColorDefinition {
return (
(htmlColorNames as string[]).includes(value.toLowerCase()) ||
/^#[0-9a-f]{3}([0-9a-f]{3})?$/i.test(value) ||
/^rgb\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})\s*\)$/i.test(value)
);
}
export function parseAspectRatio(aspectRatio: TransformOptions['aspectRatio']) {
if (!aspectRatio) {
return undefined;
}
// parse aspect ratio strings, if required (ex: "16:9")
if (typeof aspectRatio === 'number') {
return aspectRatio;
} else {
const [width, height] = aspectRatio.split(':');
return parseInt(width) / parseInt(height);
}
}
/**
* Defines the original image and transforms that need to be applied to it.
*/
export interface TransformOptions {
/**
* Source for the original image file.
*
* For images in your project's repository, use the `src` relative to the `public` directory.
* For remote images, provide the full URL.
*/
src: string;
/**
* The output format to be used in the optimized image.
*
* @default undefined The original image format will be used.
*/
format?: OutputFormat;
/**
* The compression quality used during optimization.
*
* @default undefined Allows the image service to determine defaults.
*/
quality?: number;
/**
* The desired width of the output image. Combine with `height` to crop the image
* to an exact size, or `aspectRatio` to automatically calculate and crop the height.
*/
width?: number;
/**
* The desired height of the output image. Combine with `height` to crop the image
* to an exact size, or `aspectRatio` to automatically calculate and crop the width.
*/
height?: number;
/**
* The desired aspect ratio of the output image. Combine with either `width` or `height`
* to automatically calculate and crop the other dimension.
*
* @example 1.777 - numbers can be used for computed ratios, useful for doing `{width/height}`
* @example "16:9" - strings can be used in the format of `{ratioWidth}:{ratioHeight}`.
*/
aspectRatio?: number | `${number}:${number}`;
/**
* The background color to use when converting from a transparent image format to a
* non-transparent format. This is useful for converting PNGs to JPEGs.
*
* @example "white" - a named color
* @example "#ffffff" - a hex color
* @example "rgb(255, 255, 255)" - an rgb color
*/
background?: ColorDefinition;
/**
* How the image should be resized to fit both `height` and `width`.
*
* @default 'cover'
*/
fit?: CropFit;
/**
* Position of the crop when fit is `cover` or `contain`.
*
* @default 'centre'
*/
position?: CropPosition;
}
export interface HostedImageService {
/**
* Gets the HTML attributes needed for the server rendered `
` element.
*/
getImageAttributes(transform: T): Promise;
}
export interface SSRImageService
extends HostedImageService {
/**
* Gets the HTML attributes needed for the server rendered `
` element.
*/
getImageAttributes(transform: T): Promise>;
/**
* Serializes image transformation properties to URLSearchParams, used to build
* the final `src` that points to the self-hosted SSR endpoint.
*
* @param transform @type {TransformOptions} defining the requested image transformation.
*/
serializeTransform(transform: T): { searchParams: URLSearchParams };
/**
* The reverse of `serializeTransform(transform)`, this parsed the @type {TransformOptions} back out of a given URL.
*
* @param searchParams @type {URLSearchParams}
* @returns @type {TransformOptions} used to generate the URL, or undefined if the URL isn't valid.
*/
parseTransform(searchParams: URLSearchParams): T | undefined;
/**
* Performs the image transformations on the input image and returns both the binary data and
* final image format of the optimized image.
*
* @param inputBuffer Binary buffer containing the original image.
* @param transform @type {TransformOptions} defining the requested transformations.
*/
transform(inputBuffer: Buffer, transform: T): Promise<{ data: Buffer; format: OutputFormat }>;
}
export type ImageService =
| HostedImageService
| SSRImageService;
export function isHostedService(service: ImageService): service is ImageService {
return 'getImageSrc' in service;
}
export function isSSRService(service: ImageService): service is SSRImageService {
return 'transform' in service;
}
export abstract class BaseSSRService implements SSRImageService {
async getImageAttributes(transform: TransformOptions) {
// strip off the known attributes
const { width, height, src, format, quality, aspectRatio, ...rest } = transform;
return {
...rest,
width: width,
height: height,
};
}
serializeTransform(transform: TransformOptions) {
const searchParams = new URLSearchParams();
if (transform.quality) {
searchParams.append('q', transform.quality.toString());
}
if (transform.format) {
searchParams.append('f', transform.format);
}
if (transform.width) {
searchParams.append('w', transform.width.toString());
}
if (transform.height) {
searchParams.append('h', transform.height.toString());
}
if (transform.aspectRatio) {
searchParams.append('ar', transform.aspectRatio.toString());
}
if (transform.fit) {
searchParams.append('fit', transform.fit);
}
if (transform.background) {
searchParams.append('bg', transform.background);
}
if (transform.position) {
searchParams.append('p', encodeURI(transform.position));
}
searchParams.append('href', transform.src);
return { searchParams };
}
parseTransform(searchParams: URLSearchParams) {
if (!searchParams.has('href')) {
return undefined;
}
let transform: TransformOptions = { src: searchParams.get('href')! };
if (searchParams.has('q')) {
transform.quality = parseInt(searchParams.get('q')!);
}
if (searchParams.has('f')) {
const format = searchParams.get('f')!;
if (isOutputFormat(format)) {
transform.format = format;
}
}
if (searchParams.has('w')) {
transform.width = parseInt(searchParams.get('w')!);
}
if (searchParams.has('h')) {
transform.height = parseInt(searchParams.get('h')!);
}
if (searchParams.has('ar')) {
const ratio = searchParams.get('ar')!;
if (isAspectRatioString(ratio)) {
transform.aspectRatio = ratio;
} else {
transform.aspectRatio = parseFloat(ratio);
}
}
if (searchParams.has('fit')) {
transform.fit = searchParams.get('fit') as typeof transform.fit;
}
if (searchParams.has('p')) {
transform.position = decodeURI(searchParams.get('p')!) as typeof transform.position;
}
if (searchParams.has('bg')) {
transform.background = searchParams.get('bg') as ColorDefinition;
}
return transform;
}
abstract transform(
inputBuffer: Buffer,
transform: TransformOptions
): Promise<{ data: Buffer; format: OutputFormat }>;
}