mirror of
https://github.com/withastro/astro.git
synced 2024-12-16 21:46:22 -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
|
||||
`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.
|
||||
|
|
|
@ -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}>
|
||||
|
|
|
@ -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)));
|
||||
|
|
|
@ -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> {
|
||||
|
|
|
@ -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('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')) {
|
||||
const background = searchParams.get('bg')!;
|
||||
if (isColor(background)) {
|
||||
transform.background = background;
|
||||
}
|
||||
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
|
||||
|
|
|
@ -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);
|
||||
|
|
Loading…
Reference in a new issue