Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions .changeset/quiet-hats-sit.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
---
"@solidjs/image": minor
---

Images are no longer enlarged. Sizes wider than the source are dropped and the source width is used instead, so `srcset` only lists real widths.

Photos are turned upright using their EXIF orientation, and their reported width and height match how they display.

Animated images keep every frame when the output is WebP. Other formats keep the first frame instead of stacking every frame into one image.

JPEG files are smaller at the same quality, and WebP and PNG are compressed harder. The extra encoding time is only paid once, since results are cached.

The build cache now keys on file content instead of modification time, so it still hits after a fresh checkout in CI.

`publicPath` now defaults to Vite's public directory, so processed images are reachable on the dev server without setting it.

Every `img` now carries its intrinsic `width` and `height`, and the `noscript` copy uses `loading="lazy"`.
10 changes: 7 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -248,15 +248,19 @@ Handles imports ending in `?image`.
| `quality` | `number` | `80` | Quality passed to sharp, from 1 to 100. |
| `input` | `SolidImageFormat[]` | `["png", "jpeg", "webp"]` | Source formats to process. Other files are left alone. |
| `output` | `SolidImageFormat[]` | `["png", "jpeg", "webp"]` | Formats to emit. |
| `publicPath` | `string` | `"dist"` | Directory the processed files are written to. |
| `publicPath` | `string` | Vite's `publicDir` | Directory the dev server writes processed files to. |
| `placeholder` | `boolean \| { size?: number }` | `true` | Inline preview of the image. Set a `size` in pixels, or `false` to skip it. |

- One file is emitted per output format and per size. `output: ["webp", "jpeg"]` with `sizes: [480, 800]` gives four files per image.
- Sizes wider than the source are dropped and replaced by the source width. An image is never enlarged.
- Photos are turned upright using their EXIF orientation.
- Animated images keep every frame in WebP. Other formats keep the first frame.
- JPEG uses mozjpeg and WebP uses its highest effort. PNG is lossless, so `quality` does not apply to it.
- On build the files go through the bundler as assets, so `base`, `assetsDir` and the build manifest apply to them. Nothing is written to `publicPath`.
- On the dev server the files are written to `<publicPath>/.image/i-<hash>-<width>.<ext>` and served from `/.image/...`.
- `publicPath` should be served at the root of your site. Add `.image` to `.gitignore` when it sits inside a checked in directory such as `public`.
- `publicPath` defaults to Vite's public directory, which the dev server serves at the root of the site. Add `.image` to `.gitignore`.
- The `<img>` falls back to the largest size of the last output format. The original file is never imported, so it does not reach the bundle.
- The hash covers the source path, the size and modification time of the source file, the format, the width and the quality.
- The hash covers the content of the source file, the format, the width and the quality. It leaves out the path and the modification time, so a fresh checkout in CI still hits the cache.
- An image is encoded once and reused. The dev server reuses the file in `publicPath`. A build reuses its copy in the Vite cache directory.
- Editing an image or changing an option produces a new name, so a stale file is never served.

Expand Down
36 changes: 34 additions & 2 deletions src/__tests__/components.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -269,7 +269,8 @@ describe("SolidImage SSR", () => {
/>
));

expect(html).not.toContain("loading");
// Match the fallback element itself. The noscript image has a loading attribute.
expect(html).not.toContain("<div>loading</div>");
});

it("gives the img a srcset from the least preferred format", () => {
Expand All @@ -290,7 +291,8 @@ describe("SolidImage SSR", () => {
));

// Without this the browser falls back to the full size original.
expect(html).toContain('srcset="/hero-400.jpg 400w,/hero-800.jpg 800w" alt="hero"');
const img = /<img(?![^>]*data:image)[^>]*>/.exec(html)![0];
expect(img).toContain('srcset="/hero-400.jpg 400w,/hero-800.jpg 800w"');
});

it("gives the img no srcset when there is no transformer", () => {
Expand Down Expand Up @@ -345,6 +347,36 @@ describe("SolidImage SSR", () => {
expect(noscript).toContain('alt="hero"');
});

it("gives every img its intrinsic width and height", () => {
const html = renderToString(() => (
<SolidImage
src={{ source: "/hero.png", width: 1600, height: 900, options: {} }}
alt="hero"
fallback={() => <div>loading</div>}
/>
));

// The lazy placeholder and the noscript copy both carry the size.
expect([...html.matchAll(/<img[^>]*width="1600"[^>]*height="900"/g)]).toHaveLength(2);
});

it("lets the browser defer the noscript image", () => {
const html = renderToString(() => (
<SolidImage
src={{ source: "/hero.png", width: 100, height: 100, options: {} }}
alt="hero"
fallback={() => <div>loading</div>}
/>
));

const noscript = /<noscript[^>]*>(.*?)<\/noscript>/s.exec(html)![1]!;
expect(noscript).toContain('loading="lazy"');

// The visible img is still lazy loaded by the observer, not the browser.
const outsideNoscript = html.replace(/<noscript[^>]*>.*?<\/noscript>/gs, "");
expect(outsideNoscript).not.toContain("loading=");
});

it("marks the container, aspect ratio box, picture and blocker elements", () => {
const html = renderToString(() => (
<SolidImage
Expand Down
98 changes: 92 additions & 6 deletions src/__tests__/vite-plugin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import path from "node:path";
import sharp from "sharp";
import type { Plugin } from "vite";
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
import { imagePlugin } from "../vite/index";
import { getEffectiveSizes, imagePlugin } from "../vite/index";
import type { SolidImageOptions } from "../vite/index";

// Vite hooks can be a function or an object with a handler.
Expand All @@ -21,10 +21,15 @@ function callLoad(plugin: Plugin, id: string, context: unknown = {}) {
return fn.call(context as any, id, {});
}

function callConfigResolved(plugin: Plugin, command: "build" | "serve", cacheDir?: string) {
function callConfigResolved(
plugin: Plugin,
command: "build" | "serve",
cacheDir?: string,
publicDir?: string,
) {
const hook = plugin.configResolved as any;
const fn = typeof hook === "function" ? hook : hook.handler;
fn.call({} as any, { command, cacheDir } as any);
fn.call({} as any, { command, cacheDir, publicDir } as any);
}

function getPlugin(plugins: Plugin[], name: string): Plugin {
Expand Down Expand Up @@ -153,7 +158,7 @@ describe("local images", () => {
imagePath = path.join(dir, "photo.png");

await sharp({
create: { width: 64, height: 32, channels: 3, background: "#336699" },
create: { width: 1200, height: 600, channels: 3, background: "#336699" },
})
.png()
.toFile(imagePath);
Expand Down Expand Up @@ -211,8 +216,8 @@ describe("local images", () => {
const plugin = createLocalPlugin();
const code: string = await callLoad(plugin, path.join(dir, "photo.png?image-source"));

expect(code).toContain("width: 64");
expect(code).toContain("height: 32");
expect(code).toContain("width: 1200");
expect(code).toContain("height: 600");
});

it("points the source at the largest variant of the fallback format", async () => {
Expand Down Expand Up @@ -425,6 +430,69 @@ describe("local images", () => {
expect(first).not.toBe(second);
});

it("drops sizes wider than the source and adds the source width", async () => {
const smallPath = path.join(dir, "small.png");
await sharp({ create: { width: 600, height: 300, channels: 3, background: "#336699" } })
.png()
.toFile(smallPath);

const plugin = createLocalPlugin({ output: ["webp"], sizes: [400, 800, 1200] });
const code: string = await callLoad(plugin, path.join(dir, "small.png?image-transformer"));

expect(code).toContain('"./small.png?image-webp-400"');
expect(code).toContain('"./small.png?image-webp-600"');
expect(code).not.toContain("image-webp-800");
expect(code).not.toContain("image-webp-1200");
});

it("points the source at the source width when every size is too wide", async () => {
const smallPath = path.join(dir, "tiny.png");
await sharp({ create: { width: 300, height: 150, channels: 3, background: "#336699" } })
.png()
.toFile(smallPath);

const plugin = createLocalPlugin({ output: ["jpeg"], sizes: [800, 1200] });
const code: string = await callLoad(plugin, path.join(dir, "tiny.png?image-source"));

expect(code).toContain('import source from "./tiny.png?image-raw-jpeg-300"');
});

it("gives the same file name to the same content at another path and time", async () => {
const copyPath = path.join(dir, "copy.png");
await fs.copyFile(path.join(dir, "photo.png"), copyPath);
// A fresh checkout gives every file a new modification time.
await fs.utimes(copyPath, new Date(2001, 0, 1), new Date(2001, 0, 1));

const plugin = createLocalPlugin();
const original: string = await callLoad(plugin, path.join(dir, "photo.png?image-raw-webp-400"));
const copy: string = await callLoad(plugin, path.join(dir, "copy.png?image-raw-webp-400"));

expect(copy).toBe(original);
});

it("writes to Vite's public directory when no publicPath is given", async () => {
const publicDir = path.join(dir, "vite-public");
const plugin = createLocalPlugin({ publicPath: undefined });
callConfigResolved(plugin, "serve", path.join(dir, "cache"), publicDir);

const code: string = await callLoad(plugin, path.join(dir, "photo.png?image-raw-webp-400"));
const publicUrl = /export default "(.+)"/.exec(code)![1]!;

expect((await fs.stat(path.join(publicDir, publicUrl))).isFile()).toBe(true);
});

it("keeps an explicit publicPath over Vite's public directory", async () => {
const publicDir = path.join(dir, "ignored-public");
const plugin = createLocalPlugin();
callConfigResolved(plugin, "serve", path.join(dir, "cache"), publicDir);

const code: string = await callLoad(plugin, path.join(dir, "photo.png?image-raw-webp-400"));
const publicUrl = /export default "(.+)"/.exec(code)![1]!;

expect((await fs.stat(path.join(publicPath, publicUrl))).isFile()).toBe(true);
await expect(fs.stat(publicDir)).rejects.toThrow();
});

it("ignores a file extension that is not in the input list", async () => {
const plugin = createLocalPlugin({ input: ["jpeg"] });

Expand Down Expand Up @@ -471,3 +539,21 @@ describe("local images", () => {
expect(code).toContain("variant_webp_400");
});
});

describe("getEffectiveSizes", () => {
it("keeps every size that fits inside the source", () => {
expect(getEffectiveSizes([400, 800], 1200)).toEqual([400, 800]);
});

it("replaces sizes wider than the source with the source width", () => {
expect(getEffectiveSizes([400, 800, 1200], 600)).toEqual([400, 600]);
});

it("keeps a size equal to the source width once", () => {
expect(getEffectiveSizes([600, 1200], 600)).toEqual([600]);
});

it("keeps the sizes as given when the source width is unknown", () => {
expect(getEffectiveSizes([400, 400, 800], 0)).toEqual([400, 800]);
});
});
90 changes: 90 additions & 0 deletions src/__tests__/vite-transformers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,16 +7,50 @@ import { getImageData, transformImage } from "../vite/transformers";

let dir: string;
let imagePath: string;
let rotatedPath: string;
let animatedPath: string;
let noisePath: string;

// Fills an image with pseudo random pixels. A flat color compresses to almost
// nothing in every encoder, so it cannot show a difference in file size.
function createNoise(width: number, height: number) {
const pixels = Buffer.alloc(width * height * 3);
let seed = 42;
for (let i = 0; i < pixels.length; i += 1) {
seed = (seed * 1103515245 + 12345) % 2147483648;
pixels[i] = seed % 256;
}
return sharp(pixels, { raw: { width, height, channels: 3 } });
}

beforeAll(async () => {
dir = await fs.mkdtemp(path.join(os.tmpdir(), "solid-image-sharp-"));
imagePath = path.join(dir, "photo.png");
rotatedPath = path.join(dir, "rotated.jpg");
animatedPath = path.join(dir, "animated.webp");
noisePath = path.join(dir, "noise.png");

await sharp({
create: { width: 800, height: 400, channels: 3, background: "#112233" },
})
.png()
.toFile(imagePath);

// Stored landscape, displayed portrait. This is how phones save photos.
await sharp({ create: { width: 64, height: 32, channels: 3, background: "#112233" } })
.jpeg()
.withMetadata({ orientation: 6 })
.toFile(rotatedPath);

const frame = (background: string) =>
sharp({ create: { width: 40, height: 20, channels: 3, background } }).png().toBuffer();
await sharp([await frame("#ff0000"), await frame("#00ff00"), await frame("#0000ff")], {
join: { animated: true },
})
.webp()
.toFile(animatedPath);

await createNoise(256, 256).png().toFile(noisePath);
});

afterAll(async () => {
Expand All @@ -31,6 +65,10 @@ describe("getImageData", () => {
it("rejects for a missing file", async () => {
await expect(getImageData(path.join(dir, "missing.png"))).rejects.toThrow();
});

it("reports the displayed size of a rotated photo", async () => {
expect(await getImageData(rotatedPath)).toEqual({ width: 32, height: 64 });
});
});

describe("transformImage", () => {
Expand Down Expand Up @@ -61,3 +99,55 @@ describe("transformImage", () => {
}
});
});

describe("transformImage output", () => {
it("never enlarges an image past its own width", async () => {
const buffer = await transformImage(imagePath, "webp", 1600, 80).toBuffer();
const meta = await sharp(buffer).metadata();

expect(meta.width).toBe(800);
expect(meta.height).toBe(400);
});

it("applies the EXIF orientation", async () => {
const buffer = await transformImage(rotatedPath, "webp", 16, 80).toBuffer();
const meta = await sharp(buffer).metadata();

expect(meta.width).toBe(16);
expect(meta.height).toBe(32);
});

it("keeps every frame of an animated image in WebP", async () => {
const buffer = await transformImage(animatedPath, "webp", 20, 80).toBuffer();
const meta = await sharp(buffer, { animated: true }).metadata();

expect(meta.pages).toBe(3);
expect(meta.width).toBe(20);
expect(meta.pageHeight).toBe(10);
});

it("keeps only the first frame in formats that cannot animate", async () => {
for (const format of ["avif", "jpeg", "png"] as const) {
const buffer = await transformImage(animatedPath, format, 20, 80).toBuffer();
const meta = await sharp(buffer).metadata();

// A stacked strip of frames would be 30 pixels tall.
expect(meta.width).toBe(20);
expect(meta.height).toBe(10);
}
});

it("encodes JPEG smaller than the default encoder at the same quality", async () => {
const tuned = await transformImage(noisePath, "jpeg", 256, 80).toBuffer();
const plain = await sharp(noisePath).jpeg({ quality: 80 }).toBuffer();

expect(tuned.length).toBeLessThan(plain.length);
});

it("encodes PNG no larger than the default encoder", async () => {
const tuned = await transformImage(noisePath, "png", 256, 80).toBuffer();
const plain = await sharp(noisePath).png().toBuffer();

expect(tuned.length).toBeLessThanOrEqual(plain.length);
});
});
9 changes: 9 additions & 0 deletions src/core/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,8 @@ export function SolidImage<T>(props: SolidImageProps<T>): JSX.Element {
src={serverSrc()}
srcset={props.eager ? fallbackSrcSet() : undefined}
sizes={props.eager ? props.sizes : undefined}
width={width()}
height={height()}
alt={props.alt}
crossOrigin={props.crossOrigin}
fetchpriority={props.fetchPriority}
Expand All @@ -170,6 +172,8 @@ export function SolidImage<T>(props: SolidImageProps<T>): JSX.Element {
src={props.src.source}
srcset={fallbackSrcSet()}
sizes={props.sizes}
width={width()}
height={height()}
alt={props.alt}
onLoad={() => {
if (!defer()) {
Expand All @@ -192,11 +196,16 @@ export function SolidImage<T>(props: SolidImageProps<T>): JSX.Element {
<ClientOnly
fallback={
<noscript>
{/* Without JavaScript there is no observer, so let the browser
defer offscreen images itself. */}
<img
data-solid-image="image"
src={props.src.source}
srcset={fallbackSrcSet()}
sizes={props.sizes}
width={width()}
height={height()}
loading="lazy"
alt={props.alt}
crossOrigin={props.crossOrigin}
decoding={props.decoding}
Expand Down
Loading
Loading