mirror of
https://github.com/withastro/astro.git
synced 2025-01-06 22:10:10 -05:00
[@astrojs/image] support additional resize options (#4438)
* working draft * more sharp params * add changeset * fix typing * wip * add missing docblocks * update lock file * remove enlargement and reduction resize options * add tests * Add docs * support crop options in pictures * cleanup * define crop types in docs * cleanup * remove kernel option Co-authored-by: Tony Sullivan <tony.f.sullivan@outlook.com>
This commit is contained in:
parent
2737cabd10
commit
1e5d8ba9af
7 changed files with 139 additions and 12 deletions
5
.changeset/breezy-flowers-pay.md
Normal file
5
.changeset/breezy-flowers-pay.md
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
---
|
||||||
|
'@astrojs/image': minor
|
||||||
|
---
|
||||||
|
|
||||||
|
Support additional Sharp resize options
|
|
@ -205,7 +205,27 @@ The parameter can be a [named HTML color](https://www.w3schools.com/tags/ref_col
|
||||||
color representation with 3 or 6 hexadecimal characters in the form `#123[abc]`, or an RGB definition in the form
|
color representation with 3 or 6 hexadecimal characters in the form `#123[abc]`, or an RGB definition in the form
|
||||||
`rgb(100,100,100)`.
|
`rgb(100,100,100)`.
|
||||||
|
|
||||||
### `<Picture /`>
|
#### fit
|
||||||
|
|
||||||
|
<p>
|
||||||
|
|
||||||
|
**Type:** `'cover' | 'contain' | 'fill' | 'inside' | 'outside'` <br>
|
||||||
|
**Default:** `'cover'`
|
||||||
|
</p>
|
||||||
|
|
||||||
|
How the image should be resized to fit both `height` and `width`.
|
||||||
|
|
||||||
|
#### position
|
||||||
|
|
||||||
|
<p>
|
||||||
|
|
||||||
|
**Type:** `'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'` <br>
|
||||||
|
**Default:** `'centre'`
|
||||||
|
</p>
|
||||||
|
|
||||||
|
Position of the crop when fit is `cover` or `contain`.
|
||||||
|
|
||||||
|
### `<Picture />`
|
||||||
|
|
||||||
#### src
|
#### src
|
||||||
|
|
||||||
|
@ -304,6 +324,28 @@ The parameter can be a [named HTML color](https://www.w3schools.com/tags/ref_col
|
||||||
color representation with 3 or 6 hexadecimal characters in the form `#123[abc]`, or an RGB definition in the form
|
color representation with 3 or 6 hexadecimal characters in the form `#123[abc]`, or an RGB definition in the form
|
||||||
`rgb(100,100,100)`.
|
`rgb(100,100,100)`.
|
||||||
|
|
||||||
|
#### fit
|
||||||
|
|
||||||
|
<p>
|
||||||
|
|
||||||
|
**Type:** `'cover' | 'contain' | 'fill' | 'inside' | 'outside'` <br>
|
||||||
|
**Default:** `'cover'`
|
||||||
|
</p>
|
||||||
|
|
||||||
|
How the image should be resized to fit both `height` and `width`.
|
||||||
|
|
||||||
|
#### position
|
||||||
|
|
||||||
|
<p>
|
||||||
|
|
||||||
|
**Type:** `'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'` <br>
|
||||||
|
**Default:** `'centre'`
|
||||||
|
</p>
|
||||||
|
|
||||||
|
Position of the crop when fit is `cover` or `contain`.
|
||||||
|
|
||||||
### `getImage`
|
### `getImage`
|
||||||
|
|
||||||
This is the helper function used by the `<Image />` component to build `<img />` attributes for the transformed image. This helper can be used directly for more complex use cases that aren't currently supported by the `<Image />` component.
|
This is the helper function used by the `<Image />` component to build `<img />` attributes for the transformed image. This helper can be used directly for more complex use cases that aren't currently supported by the `<Image />` component.
|
||||||
|
|
|
@ -38,7 +38,9 @@ const {
|
||||||
sizes,
|
sizes,
|
||||||
widths,
|
widths,
|
||||||
aspectRatio,
|
aspectRatio,
|
||||||
|
fit,
|
||||||
background,
|
background,
|
||||||
|
position,
|
||||||
formats = ['avif', 'webp'],
|
formats = ['avif', 'webp'],
|
||||||
loading = 'lazy',
|
loading = 'lazy',
|
||||||
decoding = 'async',
|
decoding = 'async',
|
||||||
|
@ -49,7 +51,15 @@ if (alt === undefined || alt === null) {
|
||||||
warnForMissingAlt();
|
warnForMissingAlt();
|
||||||
}
|
}
|
||||||
|
|
||||||
const { image, sources } = await getPicture({ src, widths, formats, aspectRatio, background });
|
const { image, sources } = await getPicture({
|
||||||
|
src,
|
||||||
|
widths,
|
||||||
|
formats,
|
||||||
|
aspectRatio,
|
||||||
|
fit,
|
||||||
|
background,
|
||||||
|
position,
|
||||||
|
});
|
||||||
---
|
---
|
||||||
|
|
||||||
<picture {...attrs}>
|
<picture {...attrs}>
|
||||||
|
|
|
@ -10,7 +10,9 @@ export interface GetPictureParams {
|
||||||
widths: number[];
|
widths: number[];
|
||||||
formats: OutputFormat[];
|
formats: OutputFormat[];
|
||||||
aspectRatio?: TransformOptions['aspectRatio'];
|
aspectRatio?: TransformOptions['aspectRatio'];
|
||||||
|
fit?: TransformOptions['fit'];
|
||||||
background?: TransformOptions['background'];
|
background?: TransformOptions['background'];
|
||||||
|
position?: TransformOptions['position'];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GetPictureResult {
|
export interface GetPictureResult {
|
||||||
|
@ -41,7 +43,7 @@ async function resolveFormats({ src, formats }: GetPictureParams) {
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getPicture(params: GetPictureParams): Promise<GetPictureResult> {
|
export async function getPicture(params: GetPictureParams): Promise<GetPictureResult> {
|
||||||
const { src, widths } = params;
|
const { src, widths, fit, position, background } = params;
|
||||||
|
|
||||||
if (!src) {
|
if (!src) {
|
||||||
throw new Error('[@astrojs/image] `src` is required');
|
throw new Error('[@astrojs/image] `src` is required');
|
||||||
|
@ -64,8 +66,10 @@ export async function getPicture(params: GetPictureParams): Promise<GetPictureRe
|
||||||
src,
|
src,
|
||||||
format,
|
format,
|
||||||
width,
|
width,
|
||||||
|
fit,
|
||||||
|
position,
|
||||||
|
background,
|
||||||
height: Math.round(width / aspectRatio!),
|
height: Math.round(width / aspectRatio!),
|
||||||
background: params.background,
|
|
||||||
});
|
});
|
||||||
return `${img.src} ${width}w`;
|
return `${img.src} ${width}w`;
|
||||||
})
|
})
|
||||||
|
@ -84,8 +88,10 @@ export async function getPicture(params: GetPictureParams): Promise<GetPictureRe
|
||||||
src,
|
src,
|
||||||
width: Math.max(...widths),
|
width: Math.max(...widths),
|
||||||
aspectRatio,
|
aspectRatio,
|
||||||
|
fit,
|
||||||
|
position,
|
||||||
|
background,
|
||||||
format: allFormats[allFormats.length - 1],
|
format: allFormats[allFormats.length - 1],
|
||||||
background: params.background,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const sources = await Promise.all(allFormats.map((format) => getSource(format)));
|
const sources = await Promise.all(allFormats.map((format) => getSource(format)));
|
||||||
|
|
|
@ -21,6 +21,31 @@ export type ColorDefinition =
|
||||||
| `rgb(${number}, ${number}, ${number})`
|
| `rgb(${number}, ${number}, ${number})`
|
||||||
| `rgb(${number},${number},${number})`;
|
| `rgb(${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 {
|
export function isOutputFormat(value: string): value is OutputFormat {
|
||||||
return ['avif', 'jpeg', 'png', 'webp'].includes(value);
|
return ['avif', 'jpeg', 'png', 'webp'].includes(value);
|
||||||
}
|
}
|
||||||
|
@ -105,6 +130,18 @@ export interface TransformOptions {
|
||||||
* @example "rgb(255, 255, 255)" - an rgb color
|
* @example "rgb(255, 255, 255)" - an rgb color
|
||||||
*/
|
*/
|
||||||
background?: ColorDefinition;
|
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<T extends TransformOptions = TransformOptions> {
|
export interface HostedImageService<T extends TransformOptions = TransformOptions> {
|
||||||
|
|
|
@ -1,11 +1,12 @@
|
||||||
import sharp from 'sharp';
|
import sharp from 'sharp';
|
||||||
import { isAspectRatioString, isColor, isOutputFormat } from '../loaders/index.js';
|
import { ColorDefinition, isAspectRatioString, isColor, isOutputFormat } from '../loaders/index.js';
|
||||||
import type { OutputFormat, SSRImageService, TransformOptions } from './index.js';
|
import type { OutputFormat, SSRImageService, TransformOptions } from './index.js';
|
||||||
|
|
||||||
class SharpService implements SSRImageService {
|
class SharpService implements SSRImageService {
|
||||||
async getImageAttributes(transform: TransformOptions) {
|
async getImageAttributes(transform: TransformOptions) {
|
||||||
// strip off the known attributes
|
// strip off the known attributes
|
||||||
const { width, height, src, format, quality, aspectRatio, background, ...rest } = transform;
|
const { width, height, src, format, quality, aspectRatio, fit, position, background, ...rest } =
|
||||||
|
transform;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...rest,
|
...rest,
|
||||||
|
@ -37,10 +38,18 @@ class SharpService implements SSRImageService {
|
||||||
searchParams.append('ar', transform.aspectRatio.toString());
|
searchParams.append('ar', transform.aspectRatio.toString());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (transform.fit) {
|
||||||
|
searchParams.append('fit', transform.fit);
|
||||||
|
}
|
||||||
|
|
||||||
if (transform.background) {
|
if (transform.background) {
|
||||||
searchParams.append('bg', transform.background);
|
searchParams.append('bg', transform.background);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (transform.position) {
|
||||||
|
searchParams.append('p', encodeURI(transform.position));
|
||||||
|
}
|
||||||
|
|
||||||
return { searchParams };
|
return { searchParams };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -76,11 +85,16 @@ class SharpService implements SSRImageService {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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')) {
|
if (searchParams.has('bg')) {
|
||||||
const background = searchParams.get('bg')!;
|
transform.background = searchParams.get('bg') as ColorDefinition | undefined;
|
||||||
if (isColor(background)) {
|
|
||||||
transform.background = background;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return transform;
|
return transform;
|
||||||
|
@ -95,7 +109,14 @@ class SharpService implements SSRImageService {
|
||||||
if (transform.width || transform.height) {
|
if (transform.width || transform.height) {
|
||||||
const width = transform.width && Math.round(transform.width);
|
const width = transform.width && Math.round(transform.width);
|
||||||
const height = transform.height && Math.round(transform.height);
|
const height = transform.height && Math.round(transform.height);
|
||||||
sharpImage.resize(width, height);
|
|
||||||
|
sharpImage.resize({
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
fit: transform.fit,
|
||||||
|
position: transform.position,
|
||||||
|
background: transform.background,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// remove alpha channel and replace with background color if requested
|
// remove alpha channel and replace with background color if requested
|
||||||
|
|
|
@ -15,6 +15,8 @@ describe('Sharp service', () => {
|
||||||
['aspect ratio string', { src, aspectRatio: '16:9' }],
|
['aspect ratio string', { src, aspectRatio: '16:9' }],
|
||||||
['aspect ratio float', { src, aspectRatio: 1.7 }],
|
['aspect ratio float', { src, aspectRatio: 1.7 }],
|
||||||
['background color', { src, format: 'jpeg', background: '#333333' }],
|
['background color', { src, format: 'jpeg', background: '#333333' }],
|
||||||
|
['crop fit', { src, fit: 'cover' }],
|
||||||
|
['crop position', { src, position: 'center' }],
|
||||||
].forEach(([description, props]) => {
|
].forEach(([description, props]) => {
|
||||||
it(description, async () => {
|
it(description, async () => {
|
||||||
const { searchParams } = await sharp.serializeTransform(props);
|
const { searchParams } = await sharp.serializeTransform(props);
|
||||||
|
@ -32,6 +34,8 @@ describe('Sharp service', () => {
|
||||||
verifyProp(props.width, 'w');
|
verifyProp(props.width, 'w');
|
||||||
verifyProp(props.height, 'h');
|
verifyProp(props.height, 'h');
|
||||||
verifyProp(props.aspectRatio, 'ar');
|
verifyProp(props.aspectRatio, 'ar');
|
||||||
|
verifyProp(props.fit, 'fit');
|
||||||
|
verifyProp(props.position, 'p');
|
||||||
verifyProp(props.background, 'bg');
|
verifyProp(props.background, 'bg');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
@ -55,6 +59,8 @@ describe('Sharp service', () => {
|
||||||
`f=jpeg&bg=%23333333&href=${href}`,
|
`f=jpeg&bg=%23333333&href=${href}`,
|
||||||
{ src, format: 'jpeg', background: '#333333' },
|
{ src, format: 'jpeg', background: '#333333' },
|
||||||
],
|
],
|
||||||
|
['crop fit', `fit=contain&href=${href}`, { src, fit: 'contain' }],
|
||||||
|
['crop position', `p=right%20top&href=${href}`, { src, position: 'right top' }],
|
||||||
].forEach(([description, params, expected]) => {
|
].forEach(([description, params, expected]) => {
|
||||||
it(description, async () => {
|
it(description, async () => {
|
||||||
const searchParams = new URLSearchParams(params);
|
const searchParams = new URLSearchParams(params);
|
||||||
|
|
Loading…
Reference in a new issue