[@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:
Oussama Bennaci 2022-09-09 21:13:59 +01:00 committed by GitHub
parent 2737cabd10
commit 1e5d8ba9af
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 139 additions and 12 deletions

View file

@ -0,0 +1,5 @@
---
'@astrojs/image': minor
---
Support additional Sharp resize options

View file

@ -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
`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
@ -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
`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`
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.

View file

@ -38,7 +38,9 @@ const {
sizes,
widths,
aspectRatio,
fit,
background,
position,
formats = ['avif', 'webp'],
loading = 'lazy',
decoding = 'async',
@ -49,7 +51,15 @@ if (alt === undefined || alt === null) {
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}>

View file

@ -10,7 +10,9 @@ export interface GetPictureParams {
widths: number[];
formats: OutputFormat[];
aspectRatio?: TransformOptions['aspectRatio'];
fit?: TransformOptions['fit'];
background?: TransformOptions['background'];
position?: TransformOptions['position'];
}
export interface GetPictureResult {
@ -41,7 +43,7 @@ async function resolveFormats({ src, formats }: GetPictureParams) {
}
export async function getPicture(params: GetPictureParams): Promise<GetPictureResult> {
const { src, widths } = params;
const { src, widths, fit, position, background } = params;
if (!src) {
throw new Error('[@astrojs/image] `src` is required');
@ -64,8 +66,10 @@ export async function getPicture(params: GetPictureParams): Promise<GetPictureRe
src,
format,
width,
fit,
position,
background,
height: Math.round(width / aspectRatio!),
background: params.background,
});
return `${img.src} ${width}w`;
})
@ -84,8 +88,10 @@ export async function getPicture(params: GetPictureParams): Promise<GetPictureRe
src,
width: Math.max(...widths),
aspectRatio,
fit,
position,
background,
format: allFormats[allFormats.length - 1],
background: params.background,
});
const sources = await Promise.all(allFormats.map((format) => getSource(format)));

View file

@ -21,6 +21,31 @@ export type ColorDefinition =
| `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 {
return ['avif', 'jpeg', 'png', 'webp'].includes(value);
}
@ -105,6 +130,18 @@ export interface TransformOptions {
* @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<T extends TransformOptions = TransformOptions> {

View file

@ -1,11 +1,12 @@
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';
class SharpService implements SSRImageService {
async getImageAttributes(transform: TransformOptions) {
// 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 {
...rest,
@ -37,10 +38,18 @@ class SharpService implements SSRImageService {
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));
}
return { searchParams };
}
@ -76,11 +85,16 @@ class SharpService implements SSRImageService {
}
}
if (searchParams.has('bg')) {
const background = searchParams.get('bg')!;
if (isColor(background)) {
transform.background = background;
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 | undefined;
}
return transform;
@ -95,7 +109,14 @@ class SharpService implements SSRImageService {
if (transform.width || transform.height) {
const width = transform.width && Math.round(transform.width);
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

View file

@ -15,6 +15,8 @@ describe('Sharp service', () => {
['aspect ratio string', { src, aspectRatio: '16:9' }],
['aspect ratio float', { src, aspectRatio: 1.7 }],
['background color', { src, format: 'jpeg', background: '#333333' }],
['crop fit', { src, fit: 'cover' }],
['crop position', { src, position: 'center' }],
].forEach(([description, props]) => {
it(description, async () => {
const { searchParams } = await sharp.serializeTransform(props);
@ -32,6 +34,8 @@ describe('Sharp service', () => {
verifyProp(props.width, 'w');
verifyProp(props.height, 'h');
verifyProp(props.aspectRatio, 'ar');
verifyProp(props.fit, 'fit');
verifyProp(props.position, 'p');
verifyProp(props.background, 'bg');
});
});
@ -55,6 +59,8 @@ describe('Sharp service', () => {
`f=jpeg&bg=%23333333&href=${href}`,
{ 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]) => {
it(description, async () => {
const searchParams = new URLSearchParams(params);