0
Fork 0
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:
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 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.

View file

@ -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}>

View file

@ -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)));

View file

@ -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> {

View file

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

View file

@ -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);