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
9 changes: 9 additions & 0 deletions .changeset/bright-foxes-order.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
"@solidjs/image": minor
---

The default output is now WebP and JPEG. The old default listed PNG first, so every browser downloaded PNG, the largest format for photos.

Formats are now offered smallest first, whatever order `output` lists them in. The `img` falls back to JPEG or PNG, which every browser reads.

A transparent image gets PNG in place of JPEG, so its transparent pixels are no longer painted black. An opaque image drops PNG when JPEG is also listed.
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -254,11 +254,14 @@ Handles imports ending in `?image`.
| `sizes` | `number[]` | required | Output widths in pixels. Height follows the aspect ratio. |
| `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. |
| `output` | `SolidImageFormat[]` | `["webp", "jpeg"]` | Formats to emit. They are offered smallest first, whatever the order here. |
| `publicPath` | `string` | Vite's `publicDir` | Directory the dev server writes processed files to. |
| `placeholder` | `boolean \| { size?: number } \| { type: "blurhash" }` | `true` | Preview shown while the image loads. See [BlurHash preview](#blurhash-preview). |

- One file is emitted per output format and per size. `output: ["webp", "jpeg"]` with `sizes: [480, 800]` gives four files per image.
- Formats are offered in this order: AVIF, WebP, TIFF, JPEG, PNG. The browser takes the first one it reads, and the `<img>` falls back to the last.
- A transparent image gets PNG in place of JPEG, since JPEG would paint the transparent pixels black.
- An opaque image drops PNG when JPEG is also listed, since JPEG is far smaller for photos. List PNG without JPEG to keep it.
- 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.
Expand Down
85 changes: 81 additions & 4 deletions src/__tests__/vite-plugin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { isBlurhashValid } from "blurhash";
import sharp from "sharp";
import type { Plugin } from "vite";
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
import { getEffectiveSizes, imagePlugin } from "../vite/index";
import { getEffectiveFormats, getEffectiveSizes, imagePlugin } from "../vite/index";
import type { SolidImageOptions } from "../vite/index";

// Vite hooks can be a function or an object with a handler.
Expand Down Expand Up @@ -537,14 +537,61 @@ describe("local images", () => {
expect(meta.width).toBe(400);
});

it("defaults to png, jpeg and webp output when no format is given", async () => {
it("defaults to webp and jpeg output, webp first", async () => {
const plugin = createLocalPlugin({ input: undefined, output: undefined });
const code: string = await callLoad(plugin, path.join(dir, "photo.png?image-transformer"));

expect(code).toContain("variant_png_400");
expect(code.indexOf("variant_webp_400")).toBeGreaterThan(-1);
expect(code.indexOf("variant_webp_400")).toBeLessThan(code.indexOf("variant_jpeg_400"));
expect(code).not.toContain("variant_png_");
});

it("offers formats smallest first, whatever order the config lists", async () => {
const plugin = createLocalPlugin({ output: ["png", "jpeg", "webp", "avif"] });
const code: string = await callLoad(plugin, path.join(dir, "photo.png?image-transformer"));

const order = ["avif", "webp", "jpeg"].map(format => code.indexOf(`variant_${format}_400`));
expect(order.every(position => position > -1)).toBe(true);
expect([...order].sort((a, b) => a - b)).toEqual(order);
// The fixture is opaque, so PNG is dropped in favor of JPEG.
expect(code).not.toContain("variant_png_");
});

it("gives a transparent image PNG in place of JPEG", async () => {
await sharp({
create: { width: 1200, height: 600, channels: 4, background: { r: 0, g: 0, b: 0, alpha: 0.5 } },
})
.png()
.toFile(path.join(dir, "transparent.png"));

const plugin = createLocalPlugin();
const transformer: string = await callLoad(
plugin,
path.join(dir, "transparent.png?image-transformer"),
);
const source: string = await callLoad(plugin, path.join(dir, "transparent.png?image-source"));

expect(transformer).toContain("variant_webp_400");
expect(transformer).toContain("variant_png_400");
expect(transformer).not.toContain("variant_jpeg_");
// JPEG would paint the transparent pixels black, so the fallback is PNG.
expect(source).toContain('import source from "./transparent.png?image-raw-png-800"');
});

it("treats an alpha channel with only opaque pixels as opaque", async () => {
await sharp({
create: { width: 1200, height: 600, channels: 4, background: { r: 0, g: 0, b: 0, alpha: 1 } },
})
.png()
.toFile(path.join(dir, "opaque-alpha.png"));

const plugin = createLocalPlugin({ output: ["png", "jpeg"] });
const code: string = await callLoad(plugin, path.join(dir, "opaque-alpha.png?image-transformer"));

expect(code).toContain("variant_jpeg_400");
expect(code).toContain("variant_webp_400");
expect(code).not.toContain("variant_png_");
});

});

describe("blurhash placeholder", () => {
Expand Down Expand Up @@ -705,3 +752,33 @@ describe("getEffectiveSizes", () => {
expect(getEffectiveSizes([400, 400, 800], 0)).toEqual([400, 800]);
});
});

describe("getEffectiveFormats", () => {
it("sorts formats from the smallest to the most widely supported", () => {
expect(getEffectiveFormats(["jpeg", "webp", "avif"], false)).toEqual(["avif", "webp", "jpeg"]);
});

it("never leaves TIFF as the fallback", () => {
expect(getEffectiveFormats(["tiff", "jpeg"], false)).toEqual(["tiff", "jpeg"]);
});

it("drops PNG for an opaque image when JPEG is listed", () => {
expect(getEffectiveFormats(["png", "jpeg", "webp"], false)).toEqual(["webp", "jpeg"]);
});

it("keeps PNG for an opaque image when it is the only broad format", () => {
expect(getEffectiveFormats(["webp", "png"], false)).toEqual(["webp", "png"]);
});

it("swaps JPEG for PNG on a transparent image", () => {
expect(getEffectiveFormats(["webp", "jpeg"], true)).toEqual(["webp", "png"]);
});

it("does not list PNG twice when both are configured for a transparent image", () => {
expect(getEffectiveFormats(["png", "jpeg"], true)).toEqual(["png"]);
});

it("leaves formats without JPEG alone for a transparent image", () => {
expect(getEffectiveFormats(["avif", "webp"], true)).toEqual(["avif", "webp"]);
});
});
26 changes: 24 additions & 2 deletions src/__tests__/vite-transformers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,15 +65,37 @@ afterAll(async () => {

describe("getImageData", () => {
it("reads the size of an image", async () => {
expect(await getImageData(imagePath)).toEqual({ width: 800, height: 400 });
expect(await getImageData(imagePath)).toEqual({ width: 800, height: 400, transparent: false });
});

it("rejects for a missing file", async () => {
await expect(getImageData(path.join(dir, "missing.png"))).rejects.toThrow();
});

it("reports a see-through image as transparent", async () => {
const transparentPath = path.join(dir, "transparent.png");
await sharp({
create: { width: 40, height: 20, channels: 4, background: { r: 0, g: 0, b: 0, alpha: 0.5 } },
})
.png()
.toFile(transparentPath);

expect((await getImageData(transparentPath)).transparent).toBe(true);
});

it("reports an alpha channel with only opaque pixels as not transparent", async () => {
const opaquePath = path.join(dir, "opaque-alpha.png");
await sharp({
create: { width: 40, height: 20, channels: 4, background: { r: 0, g: 0, b: 0, alpha: 1 } },
})
.png()
.toFile(opaquePath);

expect((await getImageData(opaquePath)).transparent).toBe(false);
});

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

Expand Down
61 changes: 52 additions & 9 deletions src/vite/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,14 @@ import {
import xxHash32 from "./xxhash32.ts";

const DEFAULT_INPUT: SolidImageFormat[] = ["png", "jpeg", "webp"];
const DEFAULT_OUTPUT: SolidImageFormat[] = ["png", "jpeg", "webp"];
// WebP for browsers that read it, JPEG for the rest. PNG is added per image
// when the source is transparent.
const DEFAULT_OUTPUT: SolidImageFormat[] = ["webp", "jpeg"];
// Order of the `source` elements. The browser takes the first format it reads,
// so the smallest formats come first. JPEG and PNG come last because every
// browser reads them, and the last format is also the `img` fallback. TIFF only
// works in Safari, so it must never be that fallback.
const FORMAT_ORDER: SolidImageFormat[] = ["avif", "webp", "tiff", "jpeg", "png"];
// sharp takes a quality from 1 to 100.
const DEFAULT_QUALITY = 80;
// Width of the inline preview, in pixels. Small enough to stay under a
Expand All @@ -43,7 +50,11 @@ export interface SolidImageOptions {
sizes: number[];
/** Source formats to process. Other files are left alone. Defaults to png, jpeg and webp. */
input?: SolidImageFormat[];
/** Formats to emit. One file is written per format and per size. Defaults to png, jpeg and webp. */
/**
* Formats to emit. One file is written per format and per size.
* They are offered smallest first, whatever the order here.
* Defaults to webp and jpeg.
*/
output?: SolidImageFormat[];
/** Quality passed to sharp, from 1 to 100. Defaults to 80. */
quality?: number;
Expand Down Expand Up @@ -140,6 +151,34 @@ async function getPlaceholder(
}
}

/**
* Returns the formats to emit for one image, in the order they are offered.
*
* - Formats are sorted from the smallest to the most widely supported, whatever
* order the config lists them in.
* - A transparent image gets PNG in place of JPEG, since JPEG has no
* transparency and would paint it black.
* - An opaque image drops PNG when JPEG is also listed, since JPEG is far
* smaller for photos.
*/
export function getEffectiveFormats(
formats: SolidImageFormat[],
transparent: boolean,
): SolidImageFormat[] {
const result = new Set(formats);

if (result.has("jpeg")) {
if (transparent) {
result.delete("jpeg");
result.add("png");
} else {
result.delete("png");
}
}

return FORMAT_ORDER.filter(format => result.has(format));
}

/**
* Returns the widths to emit for a source of the given width.
*
Expand Down Expand Up @@ -169,7 +208,7 @@ export function getEffectiveSizes(sizes: number[], sourceWidth: number): number[
async function getImageSource(
imagePath: string,
relativePath: string,
fallback: SolidImageFormat,
outputFormat: SolidImageFormat[],
sizes: number[],
placeholder: ResolvedPlaceholder,
): Promise<string> {
Expand All @@ -178,6 +217,9 @@ async function getImageSource(
getPlaceholder(imagePath, placeholder),
]);
const largestSize = Math.max(...getEffectiveSizes(sizes, imageData.width));
// The last format is the one every browser reads, so the `img` falls back to it.
const formats = getEffectiveFormats(outputFormat, imageData.transparent);
const fallback = formats[formats.length - 1]!;
// A BlurHash is decoded in the browser. The module brings the decoder along,
// so only apps that turned the BlurHash preview on import the package.
const isBlurhash = placeholder.type === "blurhash";
Expand Down Expand Up @@ -288,9 +330,6 @@ export default {
// Replaced by Vite's public directory once the config is resolved.
let publicPath = publicPathOption ?? "public";
const placeholder = resolvePlaceholder(options.local.placeholder);
// The last output format is the least preferred one, so it is the format
// every browser is expected to read.
const fallbackFormat = outputFormat[outputFormat.length - 1]!;

const validInputFileExtensions = getValidFileExtensions(inputFormat);

Expand Down Expand Up @@ -343,15 +382,19 @@ export default {
return await getImageSource(
originalPath,
relativePath,
fallbackFormat,
outputFormat,
sizes,
placeholder,
);
}
// Get the transformer file
if (condition.startsWith("image-transformer")) {
const { width } = await getImageData(originalPath);
return getImageTransformer(relativePath, outputFormat, getEffectiveSizes(sizes, width));
const { width, transparent } = await getImageData(originalPath);
return getImageTransformer(
relativePath,
getEffectiveFormats(outputFormat, transparent),
getEffectiveSizes(sizes, width),
);
}
// Image transformer variant
if (condition.startsWith("image-raw")) {
Expand Down
11 changes: 8 additions & 3 deletions src/vite/transformers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -151,18 +151,23 @@ export async function getBlurhashData(
interface ImageData {
width: number;
height: number;
/** Whether any pixel is at least partly see-through. */
transparent: boolean;
}

/**
* Reads the intrinsic size of an image, as it is displayed.
* A photo with a rotated EXIF orientation reports its width and height swapped.
* Missing values become 0.
* Reads the intrinsic size of an image, as it is displayed, and whether it is
* transparent. A photo with a rotated EXIF orientation reports its width and
* height swapped. Missing sizes become 0.
*/
export async function getImageData(originalPath: string): Promise<ImageData> {
const result = await sharp(originalPath).metadata();
const size = result.autoOrient ?? result;
return {
width: size.width || 0,
height: size.height || 0,
// An alpha channel alone does not mean transparency. Many PNGs carry one
// with every pixel opaque, so read the pixels only when there is a channel.
transparent: result.hasAlpha ? !(await sharp(originalPath).stats()).isOpaque : false,
};
}
Loading