diff --git a/.changeset/bright-foxes-order.md b/.changeset/bright-foxes-order.md
new file mode 100644
index 0000000..9db5d35
--- /dev/null
+++ b/.changeset/bright-foxes-order.md
@@ -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.
diff --git a/README.md b/README.md
index 1c122e6..45c28ce 100644
--- a/README.md
+++ b/README.md
@@ -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 `
` 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.
diff --git a/src/__tests__/vite-plugin.test.ts b/src/__tests__/vite-plugin.test.ts
index a7d1217..8dc2fb5 100644
--- a/src/__tests__/vite-plugin.test.ts
+++ b/src/__tests__/vite-plugin.test.ts
@@ -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.
@@ -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", () => {
@@ -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"]);
+ });
+});
diff --git a/src/__tests__/vite-transformers.test.ts b/src/__tests__/vite-transformers.test.ts
index 1e5ec69..d93bfac 100644
--- a/src/__tests__/vite-transformers.test.ts
+++ b/src/__tests__/vite-transformers.test.ts
@@ -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 });
});
});
diff --git a/src/vite/index.ts b/src/vite/index.ts
index acc4979..b9a6b4a 100644
--- a/src/vite/index.ts
+++ b/src/vite/index.ts
@@ -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
@@ -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;
@@ -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.
*
@@ -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 {
@@ -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";
@@ -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);
@@ -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")) {
diff --git a/src/vite/transformers.ts b/src/vite/transformers.ts
index c2133f0..0d09067 100644
--- a/src/vite/transformers.ts
+++ b/src/vite/transformers.ts
@@ -151,12 +151,14 @@ 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 {
const result = await sharp(originalPath).metadata();
@@ -164,5 +166,8 @@ export async function getImageData(originalPath: string): Promise {
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,
};
}