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, }; }