0
Fork 0
mirror of https://github.com/withastro/astro.git synced 2025-03-17 23:11:29 -05:00

feat: add crop support to image services (#12414)

* wip: add crop support to image services

* Add tests

* Strip crop attributes

* Don't upscale

* Format

* Get build working properly

* Changes from review
This commit is contained in:
Matt Kane 2024-11-12 16:26:50 +00:00 committed by GitHub
parent 814b873732
commit 46055a63e6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 466 additions and 44 deletions

View file

@ -23,6 +23,20 @@ if (typeof props.height === 'string') {
props.height = parseInt(props.height);
}
const { experimentalResponsiveImages } = imageConfig;
const layoutClassMap = {
fixed: 'aim-fi',
responsive: 'aim-re',
};
if (experimentalResponsiveImages) {
// Apply defaults from imageConfig if not provided
props.layout ??= imageConfig.experimentalLayout;
props.fit ??= imageConfig.experimentalObjectFit ?? 'cover';
props.position ??= imageConfig.experimentalObjectPosition ?? 'center';
}
const image = await getImage(props);
const additionalAttributes: HTMLAttributes<'img'> = {};
@ -34,16 +48,7 @@ if (import.meta.env.DEV) {
additionalAttributes['data-image-component'] = 'true';
}
const { experimentalResponsiveImages } = imageConfig;
const layoutClassMap = {
fixed: 'aim-fi',
responsive: 'aim-re',
};
const cssFitValues = ['fill', 'contain', 'cover', 'scale-down'];
const objectFit = props.fit ?? imageConfig.experimentalObjectFit ?? 'cover';
const objectPosition = props.position ?? imageConfig.experimentalObjectPosition ?? 'center';
// The style prop can't be spread when using define:vars, so we need to extract it here
// @see https://github.com/withastro/compiler/issues/1050
@ -51,7 +56,7 @@ const { style = '', class: className, ...attrs } = { ...additionalAttributes, ..
---
{
experimentalResponsiveImages ? (
experimentalResponsiveImages && props.layout ? (
<img
src={image.src}
{...attrs}
@ -64,12 +69,13 @@ const { style = '', class: className, ...attrs } = { ...additionalAttributes, ..
}
<style
define:vars={experimentalResponsiveImages && {
w: image.attributes.width ?? props.width ?? image.options.width,
h: image.attributes.height ?? props.height ?? image.options.height,
fit: cssFitValues.includes(objectFit) && objectFit,
pos: objectPosition,
}}
define:vars={experimentalResponsiveImages &&
props.layout && {
w: image.attributes.width ?? props.width ?? image.options.width,
h: image.attributes.height ?? props.height ?? image.options.height,
fit: cssFitValues.includes(props.fit) && props.fit,
pos: props.position,
}}
>
/* Shared by all Astro images */
.aim {

View file

@ -26,4 +26,12 @@ export const VALID_SUPPORTED_FORMATS = [
] as const;
export const DEFAULT_OUTPUT_FORMAT = 'webp' as const;
export const VALID_OUTPUT_FORMATS = ['avif', 'png', 'webp', 'jpeg', 'jpg', 'svg'] as const;
export const DEFAULT_HASH_PROPS = ['src', 'width', 'height', 'format', 'quality'];
export const DEFAULT_HASH_PROPS = [
'src',
'width',
'height',
'format',
'quality',
'fit',
'position',
];

View file

@ -2,6 +2,12 @@ import { isRemotePath } from '@astrojs/internal-helpers/path';
import { AstroError, AstroErrorData } from '../core/errors/index.js';
import type { AstroConfig } from '../types/public/config.js';
import { DEFAULT_HASH_PROPS } from './consts.js';
import {
DEFAULT_RESOLUTIONS,
LIMITED_RESOLUTIONS,
getSizesAttribute,
getWidths,
} from './layout.js';
import { type ImageService, isLocalService } from './services/service.js';
import {
type GetImageResult,
@ -12,12 +18,6 @@ import {
} from './types.js';
import { isESMImportedImage, isRemoteImage, resolveSrc } from './utils/imageKind.js';
import { inferRemoteSize } from './utils/remoteProbe.js';
import {
DEFAULT_RESOLUTIONS,
getSizesAttribute,
getWidths,
LIMITED_RESOLUTIONS,
} from './layout.js';
export async function getConfiguredImageService(): Promise<ImageService> {
if (!globalThis?.astroAsset?.imageService) {

View file

@ -1,4 +1,4 @@
import type { ImageLayout } from '../types/public/index.js';
import type { ImageLayout } from './types.js';
// Common screen widths. These will be filtered according to the image size and layout
export const DEFAULT_RESOLUTIONS = [
@ -33,9 +33,9 @@ export const LIMITED_RESOLUTIONS = [
/**
* Gets the breakpoints for an image, based on the layout and width
*
*
* The rules are as follows:
*
*
* - For full-width layout we return all breakpoints smaller than the original image width
* - For fixed layout we return 1x and 2x the requested width, unless the original image is smaller than that.
* - For responsive layout we return all breakpoints smaller than 2x the requested width, unless the original image is smaller than that.

View file

@ -2,7 +2,12 @@ import { AstroError, AstroErrorData } from '../../core/errors/index.js';
import { isRemotePath, joinPaths } from '../../core/path.js';
import type { AstroConfig } from '../../types/public/config.js';
import { DEFAULT_HASH_PROPS, DEFAULT_OUTPUT_FORMAT, VALID_SUPPORTED_FORMATS } from '../consts.js';
import type { ImageOutputFormat, ImageTransform, UnresolvedSrcSetValue } from '../types.js';
import type {
ImageFit,
ImageOutputFormat,
ImageTransform,
UnresolvedSrcSetValue,
} from '../types.js';
import { isESMImportedImage } from '../utils/imageKind.js';
import { isRemoteAllowed } from '../utils/remotePattern.js';
@ -116,6 +121,8 @@ export type BaseServiceTransform = {
height?: number;
format: string;
quality?: string | null;
fit?: ImageFit;
position?: string;
};
const sortNumeric = (a: number, b: number) => a - b;
@ -221,7 +228,13 @@ export const baseService: Omit<LocalImageService, 'transform'> = {
// Sometimes users will pass number generated from division, which can result in floating point numbers
if (options.width) options.width = Math.round(options.width);
if (options.height) options.height = Math.round(options.height);
if (options.layout && options.width && options.height) {
options.fit ??= 'cover';
delete options.layout;
}
if (options.fit === 'none') {
delete options.fit;
}
return options;
},
getHTMLAttributes(options) {
@ -237,6 +250,8 @@ export const baseService: Omit<LocalImageService, 'transform'> = {
formats,
layout,
priority,
fit,
position,
...attributes
} = options;
return {
@ -344,6 +359,8 @@ export const baseService: Omit<LocalImageService, 'transform'> = {
h: 'height',
q: 'quality',
f: 'format',
fit: 'fit',
position: 'position',
};
Object.entries(params).forEach(([param, key]) => {
@ -366,6 +383,8 @@ export const baseService: Omit<LocalImageService, 'transform'> = {
height: params.has('h') ? parseInt(params.get('h')!) : undefined,
format: params.get('f') as ImageOutputFormat,
quality: params.get('q'),
fit: params.get('fit') as ImageFit,
position: params.get('position') ?? undefined,
};
return transform;

View file

@ -1,6 +1,6 @@
import type { FormatEnum, SharpOptions } from 'sharp';
import type { FitEnum, FormatEnum, SharpOptions } from 'sharp';
import { AstroError, AstroErrorData } from '../../core/errors/index.js';
import type { ImageOutputFormat, ImageQualityPreset } from '../types.js';
import type { ImageFit, ImageOutputFormat, ImageQualityPreset } from '../types.js';
import {
type BaseServiceTransform,
type LocalImageService,
@ -38,6 +38,16 @@ async function loadSharp() {
return sharpImport;
}
const fitMap: Record<ImageFit, keyof FitEnum> = {
fill: 'fill',
contain: 'inside',
cover: 'cover',
none: 'outside',
'scale-down': 'inside',
outside: 'outside',
inside: 'inside',
};
const sharpService: LocalImageService<SharpImageServiceConfig> = {
validateOptions: baseService.validateOptions,
getURL: baseService.getURL,
@ -46,7 +56,6 @@ const sharpService: LocalImageService<SharpImageServiceConfig> = {
getSrcSet: baseService.getSrcSet,
async transform(inputBuffer, transformOptions, config) {
if (!sharp) sharp = await loadSharp();
const transform: BaseServiceTransform = transformOptions as BaseServiceTransform;
// Return SVGs as-is
@ -62,11 +71,30 @@ const sharpService: LocalImageService<SharpImageServiceConfig> = {
// always call rotate to adjust for EXIF data orientation
result.rotate();
// Never resize using both width and height at the same time, prioritizing width.
if (transform.height && !transform.width) {
result.resize({ height: Math.round(transform.height) });
// If `fit` isn't set then use old behavior:
// - Do not use both width and height for resizing, and prioritize width over height
// - Allow enlarging images
const withoutEnlargement = Boolean(transform.fit);
if (transform.width && transform.height && transform.fit) {
const fit: keyof FitEnum = fitMap[transform.fit] ?? 'inside';
result.resize({
width: Math.round(transform.width),
height: Math.round(transform.height),
fit,
position: transform.position,
withoutEnlargement,
});
} else if (transform.height && !transform.width) {
result.resize({
height: Math.round(transform.height),
withoutEnlargement,
});
} else if (transform.width) {
result.resize({ width: Math.round(transform.width) });
result.resize({
width: Math.round(transform.width),
withoutEnlargement,
});
}
if (transform.format) {

View file

@ -1,4 +1,3 @@
import type { ImageLayout } from '../types/public/index.js';
import type { OmitPreservingIndexSignature, Simplify, WithRequired } from '../type-utils.js';
import type { VALID_INPUT_FORMATS, VALID_OUTPUT_FORMATS } from './consts.js';
import type { ImageService } from './services/service.js';
@ -7,6 +6,8 @@ export type ImageQualityPreset = 'low' | 'mid' | 'high' | 'max' | (string & {});
export type ImageQuality = ImageQualityPreset | number;
export type ImageInputFormat = (typeof VALID_INPUT_FORMATS)[number];
export type ImageOutputFormat = (typeof VALID_OUTPUT_FORMATS)[number] | (string & {});
export type ImageLayout = 'responsive' | 'fixed' | 'full-width' | 'none';
export type ImageFit = 'fill' | 'contain' | 'cover' | 'none' | 'scale-down' | (string & {});
export type AssetsGlobalStaticImagesList = Map<
string,
@ -87,6 +88,8 @@ export type ImageTransform = {
height?: number | undefined;
quality?: ImageQuality | undefined;
format?: ImageOutputFormat | undefined;
fit?: ImageFit | undefined;
position?: string | undefined;
[key: string]: any;
};
@ -157,7 +160,7 @@ type ImageSharedProps<T> = T & {
layout?: ImageLayout;
fit?: 'fill' | 'contain' | 'cover' | 'none' | 'scale-down' | (string & {});
fit?: ImageFit;
position?: string;
} & (

View file

@ -6,6 +6,7 @@ import type {
ShikiConfig,
} from '@astrojs/markdown-remark';
import type { UserConfig as OriginalViteUserConfig, SSROptions as ViteSSROptions } from 'vite';
import type { ImageFit, ImageLayout } from '../../assets/types.js';
import type { RemotePattern } from '../../assets/utils/remotePattern.js';
import type { AssetsPrefix } from '../../core/app/types.js';
import type { AstroConfigType } from '../../core/config/schema.js';
@ -1091,7 +1092,7 @@ export interface ViteUserConfig extends OriginalViteUserConfig {
* The default object-fit value for responsive images. Can be overridden by the `fit` prop on the image component.
* Requires the `experimental.responsiveImages` flag to be enabled.
*/
experimentalObjectFit?: 'contain' | 'cover' | 'fill' | 'none' | 'scale-down' | (string & {});
experimentalObjectFit?: ImageFit;
/**
* @docs
* @name image.experimentalObjectPosition
@ -1766,8 +1767,6 @@ export interface ViteUserConfig extends OriginalViteUserConfig {
};
}
export type ImageLayout = 'responsive' | 'fixed' | 'full-width' | 'none';
/**
* Resolved Astro Config
*

View file

@ -5,8 +5,8 @@ import * as cheerio from 'cheerio';
import parseSrcset from 'parse-srcset';
import { Logger } from '../dist/core/logger/core.js';
import { testImageService } from './test-image-service.js';
import { loadFixture } from './test-utils.js';
import { testRemoteImageService } from './test-remote-image-service.js';
import { loadFixture } from './test-utils.js';
describe('astro:image:layout', () => {
/** @type {import('./test-utils').Fixture} */
@ -15,8 +15,6 @@ describe('astro:image:layout', () => {
describe('local image service', () => {
/** @type {import('./test-utils').DevServer} */
let devServer;
/** @type {Array<{ type: any, level: 'error', message: string; }>} */
let logs = [];
before(async () => {
fixture = await loadFixture({
@ -134,7 +132,7 @@ describe('astro:image:layout', () => {
assert.match(style, /\.aim\[/);
assert.match(style, /\.aim-re\[/);
assert.match(style, /\.aim-fi\[/);
})
});
});
describe('srcsets', () => {
@ -180,6 +178,60 @@ describe('astro:image:layout', () => {
});
});
describe('generated URLs', () => {
let $;
before(async () => {
let res = await fixture.fetch('/fit');
let html = await res.text();
$ = cheerio.load(html);
});
it('generates width and height in image URLs when both are provided', () => {
let $img = $('#local-both img');
const aspectRatio = 300 / 400;
const srcset = parseSrcset($img.attr('srcset'));
for (const { url } of srcset) {
const params = new URL(url, 'https://example.com').searchParams;
const width = parseInt(params.get('w'));
const height = parseInt(params.get('h'));
assert.equal(width / height, aspectRatio);
}
});
it('does not pass through fit and position', async () => {
const fit = $('#fit-cover img');
assert.ok(!fit.attr('fit'));
const position = $('#position img');
assert.ok(!position.attr('position'));
});
it('sets a default fit of "cover" when no fit is provided', () => {
let $img = $('#fit-default img');
const srcset = parseSrcset($img.attr('srcset'));
for (const { url } of srcset) {
const params = new URL(url, 'https://example.com').searchParams;
assert.equal(params.get('fit'), 'cover');
}
});
it('sets a fit of "contain" when fit="contain" is provided', () => {
let $img = $('#fit-contain img');
const srcset = parseSrcset($img.attr('srcset'));
for (const { url } of srcset) {
const params = new URL(url, 'https://example.com').searchParams;
assert.equal(params.get('fit'), 'contain');
}
});
it('sets no fit when fit="none" is provided', () => {
let $img = $('#fit-none img');
const srcset = parseSrcset($img.attr('srcset'));
for (const { url } of srcset) {
const params = new URL(url, 'https://example.com').searchParams;
assert.ok(!params.has('fit'));
}
});
});
describe('remote images', () => {
describe('srcset', () => {
let $;

View file

@ -0,0 +1,206 @@
import assert from 'node:assert/strict';
import { after, before, describe, it } from 'node:test';
import { removeDir } from '@astrojs/internal-helpers/fs';
import * as cheerio from 'cheerio';
import { lookup as probe } from '../dist/assets/utils/vendor/image-size/lookup.js';
import { loadFixture } from './test-utils.js';
async function getImageDimensionsFromFixture(fixture, path) {
/** @type { Response } */
const res = await fixture.fetch(path instanceof URL ? path.pathname + path.search : path);
const buffer = await res.arrayBuffer();
const { width, height } = await probe(new Uint8Array(buffer));
return { width, height };
}
async function getImageDimensionsFromLocalFile(fixture, path) {
const buffer = await fixture.readFile(path, null);
const { width, height } = await probe(new Uint8Array(buffer));
return { width, height };
}
describe('astro image service', () => {
/** @type {import('./test-utils').Fixture} */
let fixture;
describe('dev image service', () => {
/** @type {import('./test-utils').DevServer} */
let devServer;
before(async () => {
fixture = await loadFixture({
root: './fixtures/core-image-layout/',
image: {
domains: ['unsplash.com'],
},
});
devServer = await fixture.startDevServer({});
});
after(async () => {
await devServer.stop();
});
describe('generated images', () => {
let $;
let src;
before(async () => {
const res = await fixture.fetch('/fit');
const html = await res.text();
$ = cheerio.load(html);
let $img = $('#local-both img');
src = new URL($img.attr('src'), 'http://localhost').href;
});
it('generates correct width and height when both are provided', async () => {
const url = new URL(src);
const { width, height } = await getImageDimensionsFromFixture(fixture, url);
assert.equal(width, 300);
assert.equal(height, 400);
});
it('generates correct height when only width is provided', async () => {
const url = new URL(src);
url.searchParams.delete('h');
const { width, height } = await getImageDimensionsFromFixture(fixture, url);
assert.equal(width, 300);
assert.equal(height, 200);
});
it('generates correct width when only height is provided', async () => {
const url = new URL(src);
url.searchParams.delete('w');
url.searchParams.set('h', '400');
const { width, height } = await getImageDimensionsFromFixture(fixture, url);
assert.equal(width, 600);
assert.equal(height, 400);
});
it('preserves aspect ratio when fit=inside', async () => {
const url = new URL(src);
url.searchParams.set('fit', 'inside');
const { width, height } = await getImageDimensionsFromFixture(fixture, url);
assert.equal(width, 300);
assert.equal(height, 200);
});
it('preserves aspect ratio when fit=contain', async () => {
const url = new URL(src);
url.searchParams.set('fit', 'contain');
const { width, height } = await getImageDimensionsFromFixture(fixture, url);
assert.equal(width, 300);
assert.equal(height, 200);
});
it('preserves aspect ratio when fit=outside', async () => {
const url = new URL(src);
url.searchParams.set('fit', 'outside');
const { width, height } = await getImageDimensionsFromFixture(fixture, url);
assert.equal(width, 600);
assert.equal(height, 400);
});
const originalWidth = 2316;
const originalHeight = 1544;
it('does not upscale image if requested size is larger than original', async () => {
const url = new URL(src);
url.searchParams.set('w', '3000');
url.searchParams.set('h', '2000');
const { width, height } = await getImageDimensionsFromFixture(fixture, url);
assert.equal(width, originalWidth);
assert.equal(height, originalHeight);
});
// To match old behavior, we should upscale if the requested size is larger than the original
it('does upscale image if requested size is larger than original and fit is unset', async () => {
const url = new URL(src);
url.searchParams.set('w', '3000');
url.searchParams.set('h', '2000');
url.searchParams.delete('fit');
const { width, height } = await getImageDimensionsFromFixture(fixture, url);
assert.equal(width, 3000);
assert.equal(height, 2000);
});
// To match old behavior, we should upscale if the requested size is larger than the original
it('does not upscale is only one dimension is provided and fit is set', async () => {
const url = new URL(src);
url.searchParams.set('w', '3000');
url.searchParams.delete('h');
url.searchParams.set('fit', 'cover');
const { width, height } = await getImageDimensionsFromFixture(fixture, url);
assert.equal(width, originalWidth);
assert.equal(height, originalHeight);
});
});
});
describe('build image service', () => {
before(async () => {
fixture = await loadFixture({
root: './fixtures/core-image-layout/',
});
removeDir(new URL('./fixtures/core-image-ssg/node_modules/.astro', import.meta.url));
await fixture.build();
});
describe('generated images', () => {
let $;
before(async () => {
const html = await fixture.readFile('/build/index.html');
$ = cheerio.load(html);
});
it('generates correct width and height when both are provided', async () => {
const path = $('.both img').attr('src');
const { width, height } = await getImageDimensionsFromLocalFile(fixture, path);
assert.equal(width, 300);
assert.equal(height, 400);
});
it('generates correct height when only width is provided', async () => {
const path = $('.width-only img').attr('src');
const { width, height } = await getImageDimensionsFromLocalFile(fixture, path);
assert.equal(width, 300);
assert.equal(height, 200);
});
it('generates correct width when only height is provided', async () => {
const path = $('.height-only img').attr('src');
const { width, height } = await getImageDimensionsFromLocalFile(fixture, path);
assert.equal(width, 600);
assert.equal(height, 400);
});
it('preserves aspect ratio when fit=inside', async () => {
const path = $('.fit-inside img').attr('src');
const { width, height } = await getImageDimensionsFromLocalFile(fixture, path);
assert.equal(width, 300);
assert.equal(height, 200);
});
it('preserves aspect ratio when fit=contain', async () => {
const path = $('.fit-contain img').attr('src');
const { width, height } = await getImageDimensionsFromLocalFile(fixture, path);
assert.equal(width, 300);
assert.equal(height, 200);
});
it('preserves aspect ratio when fit=outside', async () => {
const path = $('.fit-outside img').attr('src');
const { width, height } = await getImageDimensionsFromLocalFile(fixture, path);
assert.equal(width, 600);
assert.equal(height, 400);
});
const originalWidth = 2316;
const originalHeight = 1544;
it('does not upscale image if requested size is larger than original', async () => {
const path = $('.too-large img').attr('src');
const { width, height } = await getImageDimensionsFromLocalFile(fixture, path);
assert.equal(width, originalWidth);
assert.equal(height, originalHeight);
});
});
});
});

View file

@ -0,0 +1,66 @@
---
import { Image } from "astro:assets";
import penguin from "../assets/penguin.jpg";
---
<div class="both">
<Image src={penguin} alt="a penguin" width={300} height={400}/>
</div>
<div class="width-only">
<Image src={penguin} alt="a penguin" width={300}/>
</div>
<div class="height-only">
<Image src={penguin} alt="a penguin" height={400}/>
</div>
<div class="fit-contain">
<Image
src={penguin}
alt="a penguin"
width={300}
height={400}
fit="contain"
/>
</div>
<div class="fit-scale-down">
<Image
src={penguin}
alt="a penguin"
width={300}
height={400}
fit="scale-down"
/>
</div>
<div class="fit-outside">
<Image
src={penguin}
alt="a penguin"
width={300}
height={400}
fit="outside"
/>
</div>
<div class="fit-inside">
<Image
src={penguin}
alt="a penguin"
width={300}
height={400}
fit="inside"
/>
</div>
<div class="too-large">
<Image
src={penguin}
alt="a penguin"
width={3000}
height={2000}
fit="cover"
/>
</div>

View file

@ -0,0 +1,35 @@
---
import { Image } from "astro:assets";
import penguin from "../assets/penguin.jpg";
---
<div id="local-both">
<Image src={penguin} alt="a penguin" width={300} height={400}/>
</div>
<div id="fit-default">
<Image src={penguin} alt="a penguin" />
</div>
<div id="fit-fill">
<Image src={penguin} alt="a penguin" fit="fill" />
</div>
<div id="fit-contain">
<Image src={penguin} alt="a penguin" fit="contain" />
</div>
<div id="fit-cover">
<Image src={penguin} alt="a penguin" fit="cover" />
</div>
<div id="fit-scale-down">
<Image src={penguin} alt="a penguin" fit="scale-down" />
</div>
<div id="fit-inside">
<Image src={penguin} alt="a penguin" fit="inside" />
</div>
<div id="fit-none">
<Image src={penguin} alt="a penguin" fit="none" />
</div>
<div id="fit-unsupported">
<Image src={penguin} alt="a penguin" fit="unsupported" />
</div>
<div id="position">
<Image src={penguin} alt="a penguin" position="right top" />
</div>