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:
parent
814b873732
commit
46055a63e6
12 changed files with 466 additions and 44 deletions
|
@ -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 {
|
||||
|
|
|
@ -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',
|
||||
];
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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;
|
||||
} & (
|
||||
|
|
|
@ -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
|
||||
*
|
||||
|
|
|
@ -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 $;
|
||||
|
|
206
packages/astro/test/core-image-service.test.js
Normal file
206
packages/astro/test/core-image-service.test.js
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
66
packages/astro/test/fixtures/core-image-layout/src/pages/build.astro
vendored
Normal file
66
packages/astro/test/fixtures/core-image-layout/src/pages/build.astro
vendored
Normal 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>
|
35
packages/astro/test/fixtures/core-image-layout/src/pages/fit.astro
vendored
Normal file
35
packages/astro/test/fixtures/core-image-layout/src/pages/fit.astro
vendored
Normal 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>
|
Loading…
Add table
Reference in a new issue