Skip to content

Commit

Permalink
automatic sizes
Browse files Browse the repository at this point in the history
  • Loading branch information
benmccann committed Nov 3, 2023
1 parent c0c7f69 commit 05f4372
Show file tree
Hide file tree
Showing 5 changed files with 52 additions and 120 deletions.
40 changes: 11 additions & 29 deletions documentation/docs/30-advanced/60-images.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,9 +59,11 @@ Use in your `.svelte` components by using `<enhanced:img>` rather than `<img>` a
<enhanced:img src="./path/to/your/image.jpg" alt="An alt text" />
```

At build time, your `<enhanced:img>` tag will be replaced with an `<img>` wrapped by a `<picture>` providing multiple image types and sizes. It's only possible to downscale images without losing quality, which means that you should provide the highest resolution image that you need — smaller versions will be generated for the various device types that may request an image. If you're not using the [`sizes` attribute](#sveltejs-enhanced-img-srcset-and-sizes) you should provide your image at 2x resolution for HiDPI displays (a.k.a. retina displays).
At build time, your `<enhanced:img>` tag will be replaced with an `<img>` wrapped by a `<picture>` providing multiple image types and sizes. It's only possible to downscale images without losing quality, which means that you should provide the highest resolution image that you need — smaller versions will be generated for the various device types that may request an image.

Since the `<enhanced:img>` element is converted to an `<img>` element, you can style it with an `img {...}` CSS rule, but you may find it more natural to add a `class` name and target that.
You should provide your image at 2x resolution for HiDPI displays (a.k.a. retina displays). `<enhanced:img>` will automatically take care of serving smaller versions to smaller devices.

If you wish to add styles to your `<enhanced:img>`, you should add a `class` and target that.

### Dynamically choosing an image

Expand Down Expand Up @@ -103,37 +105,17 @@ const pictures = import.meta.glob(

### `srcset` and `sizes`

If you have a large image, such as a hero image taking the width of the design, you should specify `sizes` so that smaller versions are requested on smaller devices. This would typically look like:

```html
<img
srcset="image-640.png 640w, image-750.png 750w, image-828.png 828w, image-1080.png 1080w, image-1200.png 1200w, image-1280.png 1280w"
sizes="(min-width:1280px) 1280px, 100vw"
/>
```

In this example, it would be tedious to have to manually create half a dozen versions of your image, so we'll generate the `srcset` for you when you specify `sizes`.

```svelte
<enhanced:img
src="./image.png"
sizes="(min-width:1280px) 1280px, 100vw"
/>
```
`<enhanced:img>` will generate different width images and corresponding `srcset` and `sizes` attributes, so that smaller versions of your image will be served to smaller devices.

If you'd like to specify custom widths of a particular image you can do that with the `w` query parameter:
If you specify `sizes` it will take precedence over the default provided by `<enhanced:img>`, and you can also specify custom widths with the `w` query parameter:
```svelte
<enhanced:img
src="./image.png?w=1280;640;400"
sizes="(min-width:1280px) 1280px, 100vw"
/>
```

If `sizes` is specified directly as a string on the `<enhanced:img>` tag then the plugin will generate different width images and a corresponding `srcset`. If some of the `sizes` have been specified as a percentage of the viewport width using the `vw` unit then the `srcset` will filter out any values which are too small to ever be requested by the browser.

If `sizes` is not provided, then a HiDPI/Retina image and a standard resolution image will be generated. The image you provide should be 2x the resolution you wish to display so that the browser can display that image on devices with a high [device pixel ratio](https://developer.mozilla.org/en-US/docs/Web/API/Window/devicePixelRatio).

> Dynamic expressions like `sizes={computedSizes}` will not be evaluated for the purposes of automatic image generation and will be skipped.
Remember that the base image you provide should be 2x the resolution you wish to display so that the browser can better display the image on devices with a high [device pixel ratio](https://developer.mozilla.org/en-US/docs/Web/API/Window/devicePixelRatio).

### Per-image transforms

Expand All @@ -153,10 +135,10 @@ Using a content delivery network (CDN) can allow you to optimize these images dy

## Best practices

- Always provide a good `alt` text
- For each image type, use the appropriate solution from those discussed above. You can mix and match all three solutions in one project. For example, you may use Vite's built-in handling to provide images for `<meta>` tags, display images on your homepage with `@sveltejs/enhanced-img`, and display user-submitted content with a dynamic approach.
- Consider serving all images via CDN regardless of the image optimization types you use. CDNs reduce latency by distributing copies of static assets globally.
- Your original images should have a good quality/resolution and should have 2x the width it will be displayed at to serve HiDPI devices. Image processing can size images down to save bandwidth when serving smaller screens, but it would be a waste of bandwidth to invent pixels to size images up.
- Give the image a container or styling so that it is constrained and does not jump around. `width` and `height` help the browser reserving space while the image is still loading. `@sveltejs/enhanced-img` will add a `width` and `height` for you.
- For images which are much larger than the width of a mobile device (roughly 400px), such as a hero image taking the width of the page design, specify `sizes` so that smaller images can be served on smaller devices.
- For images which are much larger than the width of a mobile device (roughly 400px), such as a hero image taking the width of the page design, specify `sizes` so that smaller images can be served on smaller devices. `@sveltejs/enhanced-img` will do this for you.
- Choose one image per page which is the most important/largest one and give it `priority` so it loads faster. This gives you better web vitals scores (largest contentful paint in particular).
- For each image type, use the appropriate solution from those discussed above. You can mix and match all three solutions in one project. For example, you may use Vite's built-in handling to provide images for `<meta>` tags, display images on your homepage with `@sveltejs/enhanced-img`, and display user-submitted content with a dynamic approach.
- Consider serving all images via CDN regardless of the image optimization types you use. CDNs reduce latency by distributing copies of static assets globally.
- Always provide a good `alt` text. The Svelte compiler will warn you if you don't do this.
62 changes: 13 additions & 49 deletions packages/enhanced-img/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -70,15 +70,12 @@ async function imagetools() {
/** @type {Partial<import('vite-imagetools').VitePluginOptions>} */
const imagetools_opts = {
defaultDirectives: async ({ pathname, searchParams: qs }, metadata) => {
const { imgSizes, imgWidth } = Object.fromEntries(qs);
if (!qs.has('enhanced')) return new URLSearchParams();

const { widths, kind } = getWidths(imgWidth ?? (await metadata()).width, imgSizes);
return new URLSearchParams({
as: 'picture',
format: `avif;webp;${fallback[path.extname(pathname)] ?? 'png'}`,
w: widths.join(';'),
...(kind === 'x' && !qs.has('w') && { basePixels: widths[0].toString() })
w: get_widths(qs.get('imgWidth') ?? (await metadata()).width).join(';')
});
},
namedExports: false
Expand All @@ -91,54 +88,21 @@ async function imagetools() {
}

/**
* Derived from
* https://github.com/vercel/next.js/blob/3f25a2e747fc27da6c2166e45d54fc95e96d7895/packages/next/src/shared/lib/get-img-props.ts#L132
* under the MIT license. Copyright (c) Vercel, Inc.
* @param {number | string | undefined} width
* @param {string | null | undefined} sizes
* @param {number[]} [deviceSizes]
* @param {number[]} [imageSizes]
* @returns {{ widths: number[]; kind: 'w' | 'x' }}
* @returns {number[]}
*/
function getWidths(width, sizes, deviceSizes, imageSizes) {
function get_widths(width) {
const widths = [360, 428, 720, 856, 1366, 1536, 1920, 3072, 3840];
width = typeof width === 'string' ? parseInt(width) : width;
const chosen_device_sizes = deviceSizes || [640, 750, 828, 1080, 1200, 1920, 2048, 3840];
const all_sizes = (imageSizes || [16, 32, 48, 64, 96, 128, 256, 384]).concat(chosen_device_sizes);

if (sizes) {
// Find all the "vw" percent sizes used in the sizes prop
const viewport_width_re = /(^|\s)(1?\d?\d)vw/g;
const percent_sizes = [];
for (let match; (match = viewport_width_re.exec(sizes)); match) {
percent_sizes.push(parseInt(match[2]));
}
if (percent_sizes.length) {
const smallest_ratio = Math.min(...percent_sizes) * 0.01;
return {
widths: all_sizes.filter((s) => s >= chosen_device_sizes[0] * smallest_ratio),
kind: 'w'
};
if (typeof width === 'number') {
if (width <= 300) {
widths.push(width);
widths.push(Math.round(width * 50) / 100);
} else if (width <= 600) {
widths.push(Math.round(width * 50) / 100);
} else if (width > 3840) {
widths.push(width);
}
return { widths: all_sizes, kind: 'w' };
}
if (typeof width !== 'number') {
return { widths: chosen_device_sizes, kind: 'w' };
}

// Don't need more than 2x resolution.
// Most OLED screens that say they are 3x resolution,
// are actually 3x in the green color, but only 1.5x in the red and
// blue colors. Showing a 3x resolution image in the app vs a 2x
// resolution image will be visually the same, though the 3x image
// takes significantly more data. Even true 3x resolution screens are
// wasteful as the human eye cannot see that level of detail without
// something like a magnifying glass.
// https://blog.twitter.com/engineering/en_us/topics/infrastructure/2019/capping-image-fidelity-on-ultra-high-resolution-devices.html

// We diverge from the Next.js logic here
// You can't really scale up an image, so you can't 2x the width
// Instead the user should provide the high-res image and we'll downscale
// Also, Vercel builds specific image sizes and picks the closest from those,
// but we can just build the ones we want exactly.
return { widths: [Math.round(width / 2), width], kind: 'x' };
return widths;
}
20 changes: 10 additions & 10 deletions packages/enhanced-img/src/preprocessor.js
Original file line number Diff line number Diff line change
Expand Up @@ -48,12 +48,8 @@ export function image(opts) {

let url = src_attribute.raw.trim();

const sizes = get_attr_value(node, 'sizes');
const width = get_attr_value(node, 'width');
url += url.includes('?') ? '&' : '?';
if (sizes) {
url += 'imgSizes=' + encodeURIComponent(sizes.raw) + '&';
}
if (width) {
url += 'imgWidth=' + encodeURIComponent(width.raw) + '&';
}
Expand Down Expand Up @@ -202,15 +198,17 @@ function img_to_picture(content, node, details) {
/** @type {Array<import('svelte/types/compiler/interfaces').BaseDirective | import('svelte/types/compiler/interfaces').Attribute | import('svelte/types/compiler/interfaces').SpreadAttribute>} attributes */
const attributes = node.attributes;
const index = attributes.findIndex((attribute) => attribute.name === 'sizes');
let sizes_string = '';
let sizes = '';
if (index >= 0) {
sizes_string = ' ' + content.substring(attributes[index].start, attributes[index].end);
sizes = content.substring(attributes[index].start, attributes[index].end);
attributes.splice(index, 1);
} else {
sizes = `sizes="min(${details.image.img.w}px, 100vw)"`;
}

let res = '<picture>';
for (const [format, srcset] of Object.entries(details.image.sources)) {
res += `<source srcset="${srcset}"${sizes_string} type="image/${format}" />`;
res += `<source srcset="${srcset}" ${sizes} type="image/${format}" />`;
}
res += `<img ${img_attributes_to_markdown(content, attributes, {
src: details.image.img.src,
Expand All @@ -231,10 +229,12 @@ function dynamic_img_to_picture(content, node, src_var_name) {
/** @type {Array<import('svelte/types/compiler/interfaces').BaseDirective | import('svelte/types/compiler/interfaces').Attribute | import('svelte/types/compiler/interfaces').SpreadAttribute>} attributes */
const attributes = node.attributes;
const index = attributes.findIndex((attribute) => attribute.name === 'sizes');
let sizes_string = '';
let sizes = '';
if (index >= 0) {
sizes_string = ' ' + content.substring(attributes[index].start, attributes[index].end);
sizes = '' + content.substring(attributes[index].start, attributes[index].end);
attributes.splice(index, 1);
} else {
sizes = `sizes="min({${src_var_name}.img.w}px, 100vw)"`;
}

const details = {
Expand All @@ -248,7 +248,7 @@ function dynamic_img_to_picture(content, node, src_var_name) {
{:else}
<picture>
{#each Object.entries(${src_var_name}.sources) as [format, srcset]}
<source {srcset}${sizes_string} type={'image/' + format} />
<source {srcset} ${sizes} type={'image/' + format} />
{/each}
<img ${img_attributes_to_markdown(content, attributes, details)} />
</picture>
Expand Down
48 changes: 17 additions & 31 deletions packages/enhanced-img/test/Output.svelte
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
{
"code": "<script lang=\"ts\">
<script lang="ts">
import manual_image1 from './no.png';
import manual_image2 from './no.svg';
Expand All @@ -13,54 +12,41 @@

{foo}

<img src=\"./foo.png\" alt=\"non-enhanced test\" />
<img src="./foo.png" alt="non-enhanced test" />

<picture><source srcset=\"/1 1440w, /2 960w\" type=\"image/avif\" /><source srcset=\"/3 1440w, /4 960w\" type=\"image/webp\" /><source srcset=\"5 1440w, /6 960w\" type=\"image/png\" /><img src=/7 alt=\"basic test\" width=1440 height=1440 /></picture>
<picture><source srcset="/1 1440w, /2 960w" sizes="min(1440px, 100vw)" type="image/avif" /><source srcset="/3 1440w, /4 960w" sizes="min(1440px, 100vw)" type="image/webp" /><source srcset="5 1440w, /6 960w" sizes="min(1440px, 100vw)" type="image/png" /><img src=/7 alt="basic test" width=1440 height=1440 /></picture>

<picture><source srcset=\"/1 1440w, /2 960w\" type=\"image/avif\" /><source srcset=\"/3 1440w, /4 960w\" type=\"image/webp\" /><source srcset=\"5 1440w, /6 960w\" type=\"image/png\" /><img src=/7 width=\"5\" height=\"10\" alt=\"dimensions test\" /></picture>
<picture><source srcset="/1 1440w, /2 960w" sizes="min(1440px, 100vw)" type="image/avif" /><source srcset="/3 1440w, /4 960w" sizes="min(1440px, 100vw)" type="image/webp" /><source srcset="5 1440w, /6 960w" sizes="min(1440px, 100vw)" type="image/png" /><img src=/7 width="5" height="10" alt="dimensions test" /></picture>

<picture><source srcset=\"/1 1440w, /2 960w\" type=\"image/avif\" /><source srcset=\"/3 1440w, /4 960w\" type=\"image/webp\" /><source srcset=\"5 1440w, /6 960w\" type=\"image/png\" /><img src=/7 width=5 height=10 alt=\"unquoted dimensions test\" /></picture>
<picture><source srcset="/1 1440w, /2 960w" sizes="min(1440px, 100vw)" type="image/avif" /><source srcset="/3 1440w, /4 960w" sizes="min(1440px, 100vw)" type="image/webp" /><source srcset="5 1440w, /6 960w" sizes="min(1440px, 100vw)" type="image/png" /><img src=/7 width=5 height=10 alt="unquoted dimensions test" /></picture>

<picture><source srcset=\"/1 1440w, /2 960w\" type=\"image/avif\" /><source srcset=\"/3 1440w, /4 960w\" type=\"image/webp\" /><source srcset=\"5 1440w, /6 960w\" type=\"image/png\" /><img src=/7 alt=\"directive test\" width=1440 height=1440 /></picture>
<picture><source srcset="/1 1440w, /2 960w" sizes="min(1440px, 100vw)" type="image/avif" /><source srcset="/3 1440w, /4 960w" sizes="min(1440px, 100vw)" type="image/webp" /><source srcset="5 1440w, /6 960w" sizes="min(1440px, 100vw)" type="image/png" /><img src=/7 alt="directive test" width=1440 height=1440 /></picture>

<picture><source srcset=\"/1 1440w, /2 960w\" type=\"image/avif\" /><source srcset=\"/3 1440w, /4 960w\" type=\"image/webp\" /><source srcset=\"5 1440w, /6 960w\" type=\"image/png\" /><img src=/7 {...{foo}} alt=\"spread attributes test\" width=1440 height=1440 /></picture>
<picture><source srcset="/1 1440w, /2 960w" sizes="min(1440px, 100vw)" type="image/avif" /><source srcset="/3 1440w, /4 960w" sizes="min(1440px, 100vw)" type="image/webp" /><source srcset="5 1440w, /6 960w" sizes="min(1440px, 100vw)" type="image/png" /><img src=/7 {...{foo}} alt="spread attributes test" width=1440 height=1440 /></picture>

<picture><source srcset=\"/1 1440w, /2 960w\" sizes=\"(min-width: 60rem) 80vw, (min-width: 40rem) 90vw, 100vw\" type=\"image/avif\" /><source srcset=\"/3 1440w, /4 960w\" sizes=\"(min-width: 60rem) 80vw, (min-width: 40rem) 90vw, 100vw\" type=\"image/webp\" /><source srcset=\"5 1440w, /6 960w\" sizes=\"(min-width: 60rem) 80vw, (min-width: 40rem) 90vw, 100vw\" type=\"image/png\" /><img src=/7 alt=\"sizes test\" width=1440 height=1440 /></picture>
<picture><source srcset="/1 1440w, /2 960w" sizes="(min-width: 60rem) 80vw, (min-width: 40rem) 90vw, 100vw" type="image/avif" /><source srcset="/3 1440w, /4 960w" sizes="(min-width: 60rem) 80vw, (min-width: 40rem) 90vw, 100vw" type="image/webp" /><source srcset="5 1440w, /6 960w" sizes="(min-width: 60rem) 80vw, (min-width: 40rem) 90vw, 100vw" type="image/png" /><img src=/7 alt="sizes test" width=1440 height=1440 /></picture>

<picture><source srcset=\"/1 1440w, /2 960w\" type=\"image/avif\" /><source srcset=\"/3 1440w, /4 960w\" type=\"image/webp\" /><source srcset=\"5 1440w, /6 960w\" type=\"image/png\" /><img src=/7 on:click={foo = 'clicked an image!'} alt=\"event handler test\" width=1440 height=1440 /></picture>
<picture><source srcset="/1 1440w, /2 960w" sizes="min(1440px, 100vw)" type="image/avif" /><source srcset="/3 1440w, /4 960w" sizes="min(1440px, 100vw)" type="image/webp" /><source srcset="5 1440w, /6 960w" sizes="min(1440px, 100vw)" type="image/png" /><img src=/7 on:click={foo = 'clicked an image!'} alt="event handler test" width=1440 height=1440 /></picture>

<picture><source srcset=\"/1 1440w, /2 960w\" type=\"image/avif\" /><source srcset=\"/3 1440w, /4 960w\" type=\"image/webp\" /><source srcset=\"5 1440w, /6 960w\" type=\"image/png\" /><img src=/7 alt=\"alias test\" width=1440 height=1440 /></picture>
<picture><source srcset="/1 1440w, /2 960w" sizes="min(1440px, 100vw)" type="image/avif" /><source srcset="/3 1440w, /4 960w" sizes="min(1440px, 100vw)" type="image/webp" /><source srcset="5 1440w, /6 960w" sizes="min(1440px, 100vw)" type="image/png" /><img src=/7 alt="alias test" width=1440 height=1440 /></picture>

<picture><source srcset=\"/1 1440w, /2 960w\" type=\"image/avif\" /><source srcset=\"/3 1440w, /4 960w\" type=\"image/webp\" /><source srcset=\"5 1440w, /6 960w\" type=\"image/png\" /><img src=/7 alt=\"absolute path test\" width=1440 height=1440 /></picture>
<picture><source srcset="/1 1440w, /2 960w" sizes="min(1440px, 100vw)" type="image/avif" /><source srcset="/3 1440w, /4 960w" sizes="min(1440px, 100vw)" type="image/webp" /><source srcset="5 1440w, /6 960w" sizes="min(1440px, 100vw)" type="image/png" /><img src=/7 alt="absolute path test" width=1440 height=1440 /></picture>

{#each images as image}
{#if typeof image === 'string'}
<img src={image.img.src} alt=\"opt-in test\" width={image.img.w} height={image.img.h} />
<img src={image.img.src} alt="opt-in test" width={image.img.w} height={image.img.h} />
{:else}
<picture>
{#each Object.entries(image.sources) as [format, srcset]}
<source {srcset} type={'image/' + format} />
<source {srcset} sizes="min({image.img.w}px, 100vw)" type={'image/' + format} />
{/each}
<img src={image.img.src} alt=\"opt-in test\" width={image.img.w} height={image.img.h} />
<img src={image.img.src} alt="opt-in test" width={image.img.w} height={image.img.h} />
</picture>
{/if}
{/each}

<picture>
<source src=\"./foo.avif\" />
<source srcset=\"./foo.avif 500v ./bar.avif 100v\" />
<source srcset=\"./foo.avif, ./bar.avif 1v\" />
<source src="./foo.avif" />
<source srcset="./foo.avif 500v ./bar.avif 100v" />
<source srcset="./foo.avif, ./bar.avif 1v" />
</picture>
",
"dependencies": [],
"map": SourceMap {
"mappings": "",
"names": [],
"sourceRoot": undefined,
"sources": [
"Input.svelte",
],
"version": 3,
},
"toString": [Function],
}
2 changes: 1 addition & 1 deletion packages/enhanced-img/test/preprocessor.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ it('Image preprocess snapshot test', async () => {
{ filename }
);

expect(processed).toMatchFileSnapshot('./Output.svelte');
expect(processed.code).toMatchFileSnapshot('./Output.svelte');
});

it('parses a minimized object', () => {
Expand Down

0 comments on commit 05f4372

Please sign in to comment.