Serve and accept JPEG XL as an image format (part of #222)
check / check (push) Waiting to run

A JPEG XL source is accepted, and orig of one is JPEG XL. The format
jxl works in plain and encrypted URLs and on the generator page,
served as image/jxl; auto chooses it first when Accept names
image/jxl.

govips sends libvips a JPEG XL distance, which overrides the quality,
so q becomes a distance, keeping 100 lossy. Metadata is removed from
the image before the JPEG XL save, as govips cannot have libvips strip
it, and the image is given 72 dpi so that the EXIF block libvips 8.16
adds holds nothing from the source. The sRGB conversion moved ahead of
the format switch, and a CMYK image with no ICC profile is converted
to sRGB, as libvips cannot save CMYK as JPEG XL.

Model: opus-5-5
This commit is contained in:
2026-10-08 08:19:32 +00:00
parent 99e4aa3bb2
commit 7f186e1f80
18 changed files with 645 additions and 36 deletions
+22 -12
View File
@@ -245,7 +245,7 @@ A request whose query string cannot be decoded, or gives any parameter more than
once, is refused with 400. once, is refused with 400.
- `<format>`: one of `orig` (or `original`), `jpeg` (or `jpg`), `png`, `webp`, - `<format>`: one of `orig` (or `original`), `jpeg` (or `jpg`), `png`, `webp`,
`avif`, `gif`, or `auto` (below) `avif`, `jxl` (JPEG XL), `gif`, or `auto` (below)
- `<size>`: `orig` or `<width>x<height>` (e.g. `800x600`) - `<size>`: `orig` or `<width>x<height>` (e.g. `800x600`)
- `sig` and `exp`: the signature and its expiry, needed unless the host is - `sig` and `exp`: the signature and its expiry, needed unless the host is
allowlisted (see Signature Specification) allowlisted (see Signature Specification)
@@ -253,23 +253,29 @@ once, is refused with 400.
both optional (values under Signature Specification). Both are part of what is both optional (values under Signature Specification). Both are part of what is
cached, so each value of either is a separate cached image. cached, so each value of either is a separate cached image.
The source image may be JPEG, PNG, GIF, WebP, AVIF, JPEG XL or SVG. The
upstream's `Content-Type` must name its format, and the image's first bytes must
match it.
With the format `auto`, pixa chooses the format for each request from its With the format `auto`, pixa chooses the format for each request from its
`Accept` header, in this order: `Accept` header, in this order:
1. AVIF, when the header names `image/avif`; 1. JPEG XL, when the header names `image/jxl`;
2. WebP, when it names `image/webp`; 2. AVIF, when it names `image/avif`;
3. JPEG, when the first of `image/jpeg`, `image/*` and `*/*` that it names 3. WebP, when it names `image/webp`;
4. JPEG, when the first of `image/jpeg`, `image/*` and `*/*` that it names
allows it, or when there is no `Accept` header or it is empty. allows it, or when there is no `Accept` header or it is empty.
An entry with `q=0` refuses its format; other `q` values do not change the An entry with `q=0` refuses its format; other `q` values do not change the
order. AVIF and WebP must be named, as clients that cannot show them also send order. JPEG XL, AVIF and WebP must be named, as clients that cannot show them
`image/*` and `*/*`. pixa never sends a format the client refused: when the also send `image/*` and `*/*`. pixa never sends a format the client refused:
header allows none of the three, the answer is 406, and a header that does not when the header allows none of the four, the answer is 406, and a header that
parse, or has a `q` that is not a number from 0 to 1, is refused with 400. The does not parse, or has a `q` that is not a number from 0 to 1, is refused
signature, or the token of an encrypted URL, covers `auto` itself, so one URL with 400. The signature, or the token of an encrypted URL, covers `auto` itself,
serves every client. Each format chosen is cached as a separate image, and every so one URL serves every client. Each format chosen is cached as a separate
answer that depends on `Accept` (the image, a 304, and the 400 and 406 above) image, and every answer that depends on `Accept` (the image, a 304, and the 400
carries `Vary: Accept`, so a shared cache keeps the formats apart too. and 406 above) carries `Vary: Accept`, so a shared cache keeps the formats apart
too.
An image is served with `Cache-Control: public, max-age=<seconds>, immutable`. An image is served with `Cache-Control: public, max-age=<seconds>, immutable`.
When the URL has an expiry (an `exp`, or the TTL of an encrypted URL), `max-age` When the URL has an expiry (an `exp`, or the TTL of an encrypted URL), `max-age`
@@ -350,6 +356,10 @@ turned off.
- An image with an ICC profile is converted to sRGB first, since clients show an - An image with an ICC profile is converted to sRGB first, since clients show an
image with no profile as sRGB. Colours outside sRGB, such as the most image with no profile as sRGB. Colours outside sRGB, such as the most
saturated ones in a Display P3 photo, are clipped. saturated ones in a Display P3 photo, are clipped.
- The one exception is JPEG XL with libvips 8.16 and later: libvips then writes
an EXIF block of its own into the image, as govips cannot ask libvips to leave
it out. It holds the image's size and otherwise fixed values, such as an
orientation of 1 and a resolution of 72 dpi, and nothing from the source.
### Source Hosts ### Source Hosts
+17
View File
@@ -30,6 +30,23 @@ P2: security: per-IP rate limiting on the image routes
# Completed Steps # Completed Steps
- 2026-10-08 JPEG XL as an input and output format (part of #222): a source
whose bytes start with either JPEG XL signature, the bare codestream's `FF 0A`
or the container's, is detected as `image/jxl`, which the upstream fetch
accepts; `orig` of such a source is JPEG XL. The format `jxl` works in plain
and encrypted URLs and on the generator page, served as `image/jxl` and cached
like the other formats. `auto` chooses JPEG XL first when `Accept` names
`image/jxl`. govips sends libvips a JPEG XL distance with every save, so
libvips ignores the quality: pixa turns `q` into a distance with libvips' own
formula, keeping 100 lossy, and removes the metadata from the image before
saving, as govips cannot have libvips strip it from JPEG XL. libvips 8.16 and
later still write an EXIF block of their own into JPEG XL, from the image's
size, orientation and resolution, and fixed values; the image is upright and
is given 72 dpi before the save, so the block holds nothing from the source.
An image with an ICC profile is converted to sRGB before the JPEG XL save too,
and a CMYK image with none is converted to sRGB, as libvips cannot save CMYK
as JPEG XL. libvips' default effort, 7, is kept. JPEG XL is not yet the
default output.
- 2026-10-08 pixad runs on the Alpine release it is built on (closes #229): the - 2026-10-08 pixad runs on the Alpine release it is built on (closes #229): the
runtime stage of the `Dockerfile` uses `alpine:3.22`, the release of the runtime stage of the `Dockerfile` uses `alpine:3.22`, the release of the
`golang:1.25.4-alpine3.22` image that the test phase and the build stage use, `golang:1.25.4-alpine3.22` image that the test phase and the build stage use,
+11 -7
View File
@@ -14,8 +14,8 @@ import (
// Errors for an Accept header that an auto URL cannot be served for. // Errors for an Accept header that an auto URL cannot be served for.
var ( var (
errInvalidAccept = errors.New("invalid Accept header") errInvalidAccept = errors.New("invalid Accept header")
errNotAcceptable = errors.New( errNotAcceptable = errors.New("not acceptable: auto serves " +
"not acceptable: auto serves image/avif, image/webp or image/jpeg") "image/jxl, image/avif, image/webp or image/jpeg")
) )
// chooseAutoFormat replaces the format auto in req with the format // chooseAutoFormat replaces the format auto in req with the format
@@ -51,11 +51,11 @@ func (s *Handlers) chooseAutoFormat(
} }
// formatForAccept returns the format an auto URL is served in for the Accept // formatForAccept returns the format an auto URL is served in for the Accept
// header accept: AVIF when it names image/avif, else WebP when it names // header accept: JPEG XL when it names image/jxl, else AVIF when it names
// image/webp, else JPEG when its most specific entry of image/jpeg, image/* // image/avif, else WebP when it names image/webp, else JPEG when its most
// and */* allows it, or when it names nothing. A q of 0 refuses a format. // specific entry of image/jpeg, image/* and */* allows it, or when it names
// AVIF and WebP must be named, as clients that cannot show them send image/* // nothing. A q of 0 refuses a format. JPEG XL, AVIF and WebP must be named, as
// and */* too. // clients that cannot show them send image/* and */* too.
func formatForAccept(accept string) (imgcache.ImageFormat, error) { func formatForAccept(accept string) (imgcache.ImageFormat, error) {
qualities, err := parseAccept(accept) qualities, err := parseAccept(accept)
if err != nil { if err != nil {
@@ -66,6 +66,10 @@ func formatForAccept(accept string) (imgcache.ImageFormat, error) {
return imgcache.FormatJPEG, nil return imgcache.FormatJPEG, nil
} }
if qualities["image/jxl"] > 0 {
return imgcache.FormatJXL, nil
}
if qualities["image/avif"] > 0 { if qualities["image/avif"] > 0 {
return imgcache.FormatAVIF, nil return imgcache.FormatAVIF, nil
} }
+73
View File
@@ -0,0 +1,73 @@
package handlers
import (
"log/slog"
"net/http"
"testing"
"github.com/davidbyttow/govips/v2/vips"
"sneak.berlin/go/pixa/internal/imgcache"
)
// jxlType is the content type of JPEG XL.
const jxlType = "image/jxl"
// TestFormatForAccept_JPEGXL verifies that the format auto chooses JPEG XL
// when Accept names image/jxl, ahead of AVIF whatever their q, and AVIF when
// Accept refuses JPEG XL with q=0.
func TestFormatForAccept_JPEGXL(t *testing.T) {
t.Parallel()
tests := []struct {
name string
accept string
want imgcache.ImageFormat
}{
{"JPEG XL-capable browser",
"image/jxl,image/avif,image/webp,image/*,*/*;q=0.8", imgcache.FormatJXL},
{"JPEG XL only", jxlType, imgcache.FormatJXL},
{"JPEG XL with a lower q than AVIF", "image/avif,image/jxl;q=0.5",
imgcache.FormatJXL},
{"q=0 on JPEG XL", "image/jxl;q=0,image/avif,*/*", imgcache.FormatAVIF},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
got, err := formatForAccept(tt.accept)
if got != tt.want || err != nil {
t.Errorf("formatForAccept(%q) = %q, %v, want %q",
tt.accept, got, err, tt.want)
}
})
}
}
// TestFormatAuto_JPEGXL requests an auto URL on each image route with an
// Accept header that names image/jxl, and checks that the answer is a JPEG XL
// image.
func TestFormatAuto_JPEGXL(t *testing.T) {
t.Parallel()
h, srv := newSignedHostServer(t, slog.New(slog.DiscardHandler))
signedURL, encryptedURL := autoPhotoURLs(t, h)
for _, target := range []string{signedURL, encryptedURL} {
rec := requestImage(t, srv, http.MethodGet, target,
"image/jxl,image/avif,image/webp,*/*;q=0.8")
gotType := rec.Header().Get("Content-Type")
if rec.Code != http.StatusOK || gotType != jxlType {
t.Errorf("%s: %d %s, want 200 %s; body %s",
target, rec.Code, gotType, jxlType, rec.Body)
continue
}
if got := vips.DetermineImageType(rec.Body.Bytes()); got != vips.ImageTypeJXL {
t.Errorf("%s: body is %s, want jxl", target, vips.ImageTypes[got])
}
}
}
+2
View File
@@ -45,6 +45,7 @@ const (
contentTypeGIF = "image/gif" contentTypeGIF = "image/gif"
contentTypeWebP = "image/webp" contentTypeWebP = "image/webp"
contentTypeAVIF = "image/avif" contentTypeAVIF = "image/avif"
contentTypeJXL = "image/jxl"
contentTypeSVG = "image/svg+xml" contentTypeSVG = "image/svg+xml"
contentTypeOctetStream = "application/octet-stream" contentTypeOctetStream = "application/octet-stream"
) )
@@ -156,6 +157,7 @@ func DefaultConfig() *Config {
contentTypeGIF, contentTypeGIF,
contentTypeWebP, contentTypeWebP,
contentTypeAVIF, contentTypeAVIF,
contentTypeJXL,
contentTypeSVG, contentTypeSVG,
}, },
AllowHTTP: false, AllowHTTP: false,
@@ -0,0 +1,20 @@
package httpfetcher
import "testing"
// TestJPEGXLContentType verifies that the fetcher accepts an upstream answer
// of type image/jxl by default, and that the mock fetcher serves a .jxl file
// as image/jxl.
func TestJPEGXLContentType(t *testing.T) {
t.Parallel()
const jxlType = "image/jxl"
if !New(DefaultConfig()).isAllowedContentType(jxlType) {
t.Errorf("isAllowedContentType(%q) = false, want true", jxlType)
}
if got := detectContentTypeFromPath("images/photo.jxl"); got != jxlType {
t.Errorf("detectContentTypeFromPath(photo.jxl) = %q, want %q", got, jxlType)
}
}
+2
View File
@@ -109,6 +109,8 @@ func detectContentTypeFromPath(path string) string {
return contentTypeWebP return contentTypeWebP
case strings.HasSuffix(path, ".avif"): case strings.HasSuffix(path, ".avif"):
return contentTypeAVIF return contentTypeAVIF
case strings.HasSuffix(path, ".jxl"):
return contentTypeJXL
case strings.HasSuffix(path, ".svg"): case strings.HasSuffix(path, ".svg"):
return contentTypeSVG return contentTypeSVG
default: default:
+97 -12
View File
@@ -65,6 +65,7 @@ const (
FormatPNG Format = "png" FormatPNG Format = "png"
FormatWebP Format = "webp" FormatWebP Format = "webp"
FormatAVIF Format = "avif" FormatAVIF Format = "avif"
FormatJXL Format = "jxl"
FormatGIF Format = "gif" FormatGIF Format = "gif"
) )
@@ -305,6 +306,7 @@ const (
mimeGIF = "image/gif" mimeGIF = "image/gif"
mimeWebP = "image/webp" mimeWebP = "image/webp"
mimeAVIF = "image/avif" mimeAVIF = "image/avif"
mimeJXL = "image/jxl"
) )
// SupportedInputFormats returns MIME types this processor can read. // SupportedInputFormats returns MIME types this processor can read.
@@ -315,6 +317,7 @@ func (p *ImageProcessor) SupportedInputFormats() []string {
mimeGIF, mimeGIF,
mimeWebP, mimeWebP,
mimeAVIF, mimeAVIF,
mimeJXL,
} }
} }
@@ -326,6 +329,7 @@ func (p *ImageProcessor) SupportedOutputFormats() []Format {
FormatGIF, FormatGIF,
FormatWebP, FormatWebP,
FormatAVIF, FormatAVIF,
FormatJXL,
} }
} }
@@ -342,6 +346,8 @@ func FormatToMIME(format Format) string {
return mimeGIF return mimeGIF
case FormatAVIF: case FormatAVIF:
return mimeAVIF return mimeAVIF
case FormatJXL:
return mimeJXL
case FormatOriginal: case FormatOriginal:
return "application/octet-stream" return "application/octet-stream"
default: default:
@@ -411,9 +417,11 @@ func (p *ImageProcessor) detectFormat(img *vips.ImageRef) string {
return "webp" return "webp"
case vips.ImageTypeAVIF, vips.ImageTypeHEIF: case vips.ImageTypeAVIF, vips.ImageTypeHEIF:
return string(FormatAVIF) return string(FormatAVIF)
case vips.ImageTypeJXL:
return string(FormatJXL)
case vips.ImageTypeUnknown, vips.ImageTypeMagick, vips.ImageTypePDF, case vips.ImageTypeUnknown, vips.ImageTypeMagick, vips.ImageTypePDF,
vips.ImageTypeSVG, vips.ImageTypeTIFF, vips.ImageTypeBMP, vips.ImageTypeSVG, vips.ImageTypeTIFF, vips.ImageTypeBMP,
vips.ImageTypeJP2K, vips.ImageTypeJXL: vips.ImageTypeJP2K:
return "unknown" return "unknown"
default: default:
return "unknown" return "unknown"
@@ -485,6 +493,17 @@ func (p *ImageProcessor) encode(
quality = defaultQuality quality = defaultQuality
} }
// Stripping drops the ICC profile as well, and clients show an image
// with no profile as sRGB, so convert to sRGB first. "srgb" names
// libvips' built-in profile; govips' own sRGB path variable is set on
// first use but read without a lock, so concurrent requests race on it.
if img.HasICCProfile() {
err := img.TransformICCProfileWithFallback("srgb", "srgb")
if err != nil {
return nil, fmt.Errorf("failed to convert to sRGB: %w", err)
}
}
var params vips.ExportParams var params vips.ExportParams
switch format { switch format {
@@ -516,6 +535,9 @@ func (p *ImageProcessor) encode(
Quality: quality, Quality: quality,
} }
case FormatJXL:
return exportJXL(img, quality)
case FormatOriginal: case FormatOriginal:
return nil, fmt.Errorf("%w: %s", ErrUnsupportedOutputFormat, format) return nil, fmt.Errorf("%w: %s", ErrUnsupportedOutputFormat, format)
@@ -523,17 +545,6 @@ func (p *ImageProcessor) encode(
return nil, fmt.Errorf("%w: %s", ErrUnsupportedOutputFormat, format) return nil, fmt.Errorf("%w: %s", ErrUnsupportedOutputFormat, format)
} }
// Stripping drops the ICC profile as well, and clients show an image
// with no profile as sRGB, so convert to sRGB first. "srgb" names
// libvips' built-in profile; govips' own sRGB path variable is set on
// first use but read without a lock, so concurrent requests race on it.
if img.HasICCProfile() {
err := img.TransformICCProfileWithFallback("srgb", "srgb")
if err != nil {
return nil, fmt.Errorf("failed to convert to sRGB: %w", err)
}
}
// Drop EXIF, XMP, IPTC and the ICC profile. govips ignores this for // Drop EXIF, XMP, IPTC and the ICC profile. govips ignores this for
// GIF, which carries none of them. // GIF, which carries none of them.
params.StripMetadata = true params.StripMetadata = true
@@ -546,6 +557,78 @@ func (p *ImageProcessor) encode(
return output, nil return output, nil
} }
// jxlResolution is the resolution every JPEG XL image is saved with, in
// pixels per millimetre as libvips counts it: 72 dpi, what libvips gives a
// JPEG that names none.
const jxlResolution = 72 / 25.4
// exportJXL encodes img as JPEG XL at quality, with libvips' default effort
// and without metadata. govips sends libvips a distance, the JPEG XL
// encoder's own measure of quality, along with the quality, and libvips then
// uses the distance alone, so the quality is also given as a distance.
func exportJXL(img *vips.ImageRef, quality int) ([]byte, error) {
// libvips converts a CMYK image to sRGB before it saves WebP, AVIF or
// PNG, but cannot save one as JPEG XL. encode has already converted
// any image with an ICC profile to sRGB, so this is CMYK with none.
if img.Interpretation() == vips.InterpretationCMYK {
err := img.ToColorSpace(vips.InterpretationSRGB)
if err != nil {
return nil, fmt.Errorf("failed to convert CMYK to sRGB: %w", err)
}
}
// govips cannot make libvips strip metadata from JPEG XL, so it is
// removed from the image itself. RemoveMetadata removes EXIF, XMP and
// IPTC but keeps the ICC profile.
err := img.RemoveMetadata()
if err != nil {
return nil, err
}
err = img.RemoveICCProfile()
if err != nil {
return nil, err
}
// libvips 8.16 and later still write an EXIF block of their own, from
// the image's size, orientation and resolution, and fixed values. The
// image is upright, so its orientation is 1, but its resolution is still
// the source's.
toSave, err := img.CopyChangingResolution(jxlResolution, jxlResolution)
if err != nil {
return nil, err
}
defer toSave.Close()
params := vips.NewJxlExportParams()
params.Quality = quality
params.Distance = jxlDistance(quality)
output, _, err := toSave.ExportJxl(params)
if err != nil {
return nil, err
}
return output, nil
}
// jxlDistance turns a quality from 1 to 100 into the JPEG XL encoder's
// distance, with the formula libvips and libjxl use for their own quality
// setting, except that 100 stays lossy (distance 0.1) where libjxl makes it
// lossless.
//
//nolint:mnd // the constants of that formula
func jxlDistance(quality int) float64 {
q := float64(quality)
if quality >= 30 {
return 0.1 + (100-q)*0.09
}
return 53.0/3000.0*q*q - 23.0/20.0*q + 25.0
}
// formatFromString converts a format string to Format. // formatFromString converts a format string to Format.
func (p *ImageProcessor) formatFromString(format string) Format { func (p *ImageProcessor) formatFromString(format string) Format {
switch format { switch format {
@@ -559,6 +642,8 @@ func (p *ImageProcessor) formatFromString(format string) Format {
return FormatWebP return FormatWebP
case string(FormatAVIF): case string(FormatAVIF):
return FormatAVIF return FormatAVIF
case string(FormatJXL):
return FormatJXL
default: default:
return FormatJPEG return FormatJPEG
} }
@@ -0,0 +1,244 @@
package imageprocessor
import (
"bytes"
"io"
"math"
"os"
"testing"
"github.com/davidbyttow/govips/v2/vips"
)
// TestImageProcessor_EncodeJPEGXL converts a JPEG to a smaller JPEG XL and
// checks the content type, and the format and size the output loads as.
func TestImageProcessor_EncodeJPEGXL(t *testing.T) {
t.Parallel()
req := &Request{Size: Size{Width: 100, Height: 75}, Format: FormatJXL}
result, err := New(Params{}).Process(
t.Context(), bytes.NewReader(createTestJPEG(t, 200, 150)), req,
)
if err != nil {
t.Fatalf("Process() error = %v", err)
}
defer func() { _ = result.Content.Close() }()
if result.ContentType != "image/jxl" {
t.Errorf("ContentType = %q, want image/jxl", result.ContentType)
}
data, err := io.ReadAll(result.Content)
if err != nil {
t.Fatalf("failed to read result: %v", err)
}
output, err := vips.NewImageFromBuffer(data)
if err != nil {
t.Fatalf("failed to load the output: %v", err)
}
defer output.Close()
if output.Format() != vips.ImageTypeJXL {
t.Errorf("output format = %s, want jxl", vips.ImageTypes[output.Format()])
}
if output.Width() != 100 || output.Height() != 75 {
t.Errorf("output size = %dx%d, want 100x75", output.Width(), output.Height())
}
}
// TestImageProcessor_JPEGXLQuality verifies that the quality reaches the JPEG
// XL encoder: the same image comes out smaller at quality 30 than at 90.
func TestImageProcessor_JPEGXLQuality(t *testing.T) {
t.Parallel()
input := createTestJPEG(t, 400, 300)
outputBytes := make(map[int]int64)
for _, quality := range []int{30, 90} {
result, err := New(Params{}).Process(t.Context(),
bytes.NewReader(input), &Request{Format: FormatJXL, Quality: quality})
if err != nil {
t.Fatalf("Process() at quality %d error = %v", quality, err)
}
_ = result.Content.Close()
outputBytes[quality] = result.ContentLength
}
if outputBytes[30] >= outputBytes[90] {
t.Errorf("%d bytes at quality 30, %d at 90, want fewer at 30",
outputBytes[30], outputBytes[90])
}
}
// TestImageProcessor_JPEGXLDropsSourceEXIF verifies that none of the source's
// EXIF reaches a JPEG XL output. libvips 8.16 and later write an EXIF block of
// their own into JPEG XL (orientation, resolution, size, colour space and
// fixed defaults), and govips cannot ask them to leave it out, so the test
// looks for the source's fields rather than for no EXIF at all.
func TestImageProcessor_JPEGXLDropsSourceEXIF(t *testing.T) {
t.Parallel()
input, err := os.ReadFile("testdata/gps-exif.jpg")
if err != nil {
t.Fatalf("failed to read test JPEG: %v", err)
}
exif := processAndDecode(t, input, &Request{Format: FormatJXL}).GetExif()
for _, field := range []string{
"exif-ifd0-Make", "exif-ifd0-Model", "exif-ifd2-BodySerialNumber",
"exif-ifd2-DateTimeOriginal", "exif-ifd3-GPSLatitude",
"exif-ifd3-GPSLongitude",
} {
if value, found := exif[field]; found {
t.Errorf("output has %s: %s", field, value)
}
}
}
// TestImageProcessor_JPEGXLDropsSourceResolution verifies that the source's
// resolution does not reach the EXIF block libvips 8.16 and later write into
// JPEG XL. A JPEG XL image holds its resolution in that block alone, so the
// resolution the output loads with is the block's.
func TestImageProcessor_JPEGXLDropsSourceResolution(t *testing.T) {
t.Parallel()
// dpi-300.jpg is a flat 8x8 grey image with a resolution of 300 dpi.
input, err := os.ReadFile("testdata/dpi-300.jpg")
if err != nil {
t.Fatalf("failed to read test JPEG: %v", err)
}
output := processAndDecode(t, input, &Request{Format: FormatJXL})
// libvips gives the resolution in pixels per millimetre.
xDPI := math.Round(output.ResX() * 25.4)
yDPI := math.Round(output.ResY() * 25.4)
if xDPI == 300 || yDPI == 300 {
t.Errorf("output resolution = %vx%v dpi, the source's", xDPI, yDPI)
}
}
// TestImageProcessor_JPEGXLAppliesEXIFOrientation verifies that a JPEG XL
// output is turned upright, as TestImageProcessor_AppliesEXIFOrientation does
// for PNG.
func TestImageProcessor_JPEGXLAppliesEXIFOrientation(t *testing.T) {
t.Parallel()
// orientation-6.jpg is stored 16x8, red on the left and blue on the
// right, with EXIF orientation 6 (turn 90 degrees clockwise to view).
// Upright it is 8x16, red on top and blue below.
input, err := os.ReadFile("testdata/orientation-6.jpg")
if err != nil {
t.Fatalf("failed to read test JPEG: %v", err)
}
output := processAndDecode(t, input, &Request{Format: FormatJXL})
if output.Width() != 8 || output.Height() != 16 {
t.Fatalf("output is %dx%d, want 8x16", output.Width(), output.Height())
}
top, err := output.GetPoint(4, 0)
if err != nil {
t.Fatalf("GetPoint() error = %v", err)
}
bottom, err := output.GetPoint(4, 15)
if err != nil {
t.Fatalf("GetPoint() error = %v", err)
}
if top[0] <= top[2] || bottom[2] <= bottom[0] {
t.Errorf("top pixel = %v, bottom pixel = %v, want red above blue",
top, bottom)
}
}
// TestImageProcessor_JPEGXLConvertsWideGamutToSRGB verifies that a JPEG XL
// output is converted to sRGB, as TestImageProcessor_ConvertsWideGamutToSRGB
// does for PNG. It does not check for an ICC profile, as libvips reports one
// for every JPEG XL image it loads.
func TestImageProcessor_JPEGXLConvertsWideGamutToSRGB(t *testing.T) {
t.Parallel()
// display-p3.jpg is a flat 8x8 image with the Display P3 profile
// embedded, filled with Display P3 (234, 51, 35), which is sRGB red.
input, err := os.ReadFile("testdata/display-p3.jpg")
if err != nil {
t.Fatalf("failed to read test JPEG: %v", err)
}
output := processAndDecode(t, input, &Request{Format: FormatJXL})
pixel, err := output.GetPoint(4, 4)
if err != nil {
t.Fatalf("GetPoint() error = %v", err)
}
want := []float64{255, 0, 0}
for i := range want {
if math.Abs(pixel[i]-want[i]) > 5 {
t.Fatalf("pixel = %v, want within 5 of %v", pixel, want)
}
}
}
// TestImageProcessor_JPEGXLFromCMYK verifies that a CMYK JPEG with no ICC
// profile can be served as JPEG XL, in sRGB.
func TestImageProcessor_JPEGXLFromCMYK(t *testing.T) {
t.Parallel()
// cmyk.jpg is a flat 8x8 CMYK image with no ICC profile, filled with
// full cyan and no magenta, yellow or black.
input, err := os.ReadFile("testdata/cmyk.jpg")
if err != nil {
t.Fatalf("failed to read test JPEG: %v", err)
}
output := processAndDecode(t, input, &Request{Format: FormatJXL})
if output.Bands() != 3 {
t.Fatalf("output has %d bands, want 3", output.Bands())
}
pixel, err := output.GetPoint(4, 4)
if err != nil {
t.Fatalf("GetPoint() error = %v", err)
}
// Cyan in sRGB: little red, much green and blue.
if pixel[0] > 50 || pixel[1] < 100 || pixel[2] < 200 {
t.Errorf("pixel = %v, want cyan", pixel)
}
}
// TestJXLDistance verifies the JPEG XL distance for a few qualities: 90 is
// distance 1, the encoder's own default, and 100 stays lossy.
func TestJXLDistance(t *testing.T) {
t.Parallel()
tests := []struct {
quality int
want float64
}{
{quality: 100, want: 0.1},
{quality: 90, want: 1},
{quality: 30, want: 6.4},
{quality: 20, want: 9.0667},
}
for _, tt := range tests {
got := jxlDistance(tt.quality)
if math.Abs(got-tt.want) > 0.0001 {
t.Errorf("jxlDistance(%d) = %v, want %v", tt.quality, got, tt.want)
}
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 352 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 799 B

+4 -3
View File
@@ -21,11 +21,12 @@ const (
FormatPNG ImageFormat = "png" FormatPNG ImageFormat = "png"
FormatWebP ImageFormat = "webp" FormatWebP ImageFormat = "webp"
FormatAVIF ImageFormat = "avif" FormatAVIF ImageFormat = "avif"
FormatJXL ImageFormat = "jxl"
FormatGIF ImageFormat = "gif" FormatGIF ImageFormat = "gif"
// FormatAuto stands for AVIF, WebP or JPEG, chosen for each request // FormatAuto stands for JPEG XL, AVIF, WebP or JPEG, chosen for each
// from its Accept header once the URL's signature or token has been // request from its Accept header once the URL's signature or token has
// checked; it is never processed or cached as itself. // been checked; it is never processed or cached as itself.
FormatAuto ImageFormat = "auto" FormatAuto ImageFormat = "auto"
) )
+2
View File
@@ -275,6 +275,8 @@ func parseFormat(s string) (ImageFormat, error) {
return FormatWebP, nil return FormatWebP, nil
case "avif": case "avif":
return FormatAVIF, nil return FormatAVIF, nil
case "jxl":
return FormatJXL, nil
case "gif": case "gif":
return FormatGIF, nil return FormatGIF, nil
case "auto": case "auto":
+58
View File
@@ -0,0 +1,58 @@
package magic
import "testing"
// testMIMEJXL is the MIME type of JPEG XL.
const testMIMEJXL = "image/jxl"
// TestDetectFormatJPEGXL verifies that both JPEG XL signatures, that of a
// bare codestream and that of the container, are detected as image/jxl.
func TestDetectFormatJPEGXL(t *testing.T) {
t.Parallel()
tests := []struct {
name string
data []byte
}{
{"codestream", pad(0xFF, 0x0A)},
{"container", pad(
0x00, 0x00, 0x00, 0x0C, 0x4A, 0x58, 0x4C, 0x20, 0x0D, 0x0A, 0x87, 0x0A,
)},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
got, err := DetectFormat(tt.data)
if err != nil || got != MIMETypeJXL {
t.Errorf("DetectFormat() = %q, %v, want %q", got, err, testMIMEJXL)
}
err = ValidateMagicBytes(tt.data, testMIMEJXL)
if err != nil {
t.Errorf("ValidateMagicBytes(%q) error = %v", testMIMEJXL, err)
}
})
}
}
// TestJPEGXLMIMEType verifies that image/jxl is a supported input type and
// maps to and from the format jxl.
func TestJPEGXLMIMEType(t *testing.T) {
t.Parallel()
if !IsSupportedMIMEType(testMIMEJXL) {
t.Errorf("IsSupportedMIMEType(%q) = false", testMIMEJXL)
}
format, ok := MIMEToImageFormat(testMIMEJXL)
if format != FormatJXL || !ok {
t.Errorf("MIMEToImageFormat(%q) = %q, %v, want %q, true",
testMIMEJXL, format, ok, FormatJXL)
}
if got := ImageFormatToMIME(FormatJXL); got != testMIMEJXL {
t.Errorf("ImageFormatToMIME(%q) = %q, want %q", FormatJXL, got, testMIMEJXL)
}
}
+19 -1
View File
@@ -26,6 +26,7 @@ const (
MIMETypeWebP = MIMEType("image/webp") MIMETypeWebP = MIMEType("image/webp")
MIMETypeGIF = MIMEType("image/gif") MIMETypeGIF = MIMEType("image/gif")
MIMETypeAVIF = MIMEType("image/avif") MIMETypeAVIF = MIMEType("image/avif")
MIMETypeJXL = MIMEType("image/jxl")
MIMETypeSVG = MIMEType("image/svg+xml") MIMETypeSVG = MIMEType("image/svg+xml")
) )
@@ -40,6 +41,7 @@ const (
FormatPNG ImageFormat = "png" FormatPNG ImageFormat = "png"
FormatWebP ImageFormat = "webp" FormatWebP ImageFormat = "webp"
FormatAVIF ImageFormat = "avif" FormatAVIF ImageFormat = "avif"
FormatJXL ImageFormat = "jxl"
FormatGIF ImageFormat = "gif" FormatGIF ImageFormat = "gif"
) )
@@ -62,6 +64,11 @@ var (
// AVIF uses the ftyp box with brand "avif" or "avis" // AVIF uses the ftyp box with brand "avif" or "avis"
// Format: size(4 bytes) + "ftyp" + brand(4 bytes) // Format: size(4 bytes) + "ftyp" + brand(4 bytes)
magicFtyp = []byte{0x66, 0x74, 0x79, 0x70} // "ftyp" magicFtyp = []byte{0x66, 0x74, 0x79, 0x70} // "ftyp"
// JPEG XL is a bare codestream or a codestream in a container
magicJXLCodestream = []byte{0xFF, 0x0A}
magicJXLContainer = []byte{
0x00, 0x00, 0x00, 0x0C, 0x4A, 0x58, 0x4C, 0x20, 0x0D, 0x0A, 0x87, 0x0A,
}
) )
// WebP identifier appears at offset 8 after RIFF header. // WebP identifier appears at offset 8 after RIFF header.
@@ -115,6 +122,12 @@ func DetectFormat(data []byte) (MIMEType, error) {
} }
} }
// Check JPEG XL (FF0A, or the 12-byte container signature)
if bytes.HasPrefix(data, magicJXLCodestream) ||
bytes.HasPrefix(data, magicJXLContainer) {
return MIMETypeJXL, nil
}
// Check SVG - look for XML declaration or SVG tag // Check SVG - look for XML declaration or SVG tag
if detectSVG(data) { if detectSVG(data) {
return MIMETypeSVG, nil return MIMETypeSVG, nil
@@ -181,7 +194,8 @@ func normalizeMIMEType(mimeType string) string {
func IsSupportedMIMEType(mimeType string) bool { func IsSupportedMIMEType(mimeType string) bool {
normalized := normalizeMIMEType(mimeType) normalized := normalizeMIMEType(mimeType)
switch MIMEType(normalized) { switch MIMEType(normalized) {
case MIMETypeJPEG, MIMETypePNG, MIMETypeWebP, MIMETypeGIF, MIMETypeAVIF, MIMETypeSVG: case MIMETypeJPEG, MIMETypePNG, MIMETypeWebP, MIMETypeGIF, MIMETypeAVIF,
MIMETypeJXL, MIMETypeSVG:
return true return true
default: default:
return false return false
@@ -226,6 +240,8 @@ func MIMEToImageFormat(mimeType string) (ImageFormat, bool) {
return FormatGIF, true return FormatGIF, true
case MIMETypeAVIF: case MIMETypeAVIF:
return FormatAVIF, true return FormatAVIF, true
case MIMETypeJXL:
return FormatJXL, true
case MIMETypeSVG: case MIMETypeSVG:
// SVG has no corresponding output format. // SVG has no corresponding output format.
return "", false return "", false
@@ -247,6 +263,8 @@ func ImageFormatToMIME(format ImageFormat) string {
return string(MIMETypeGIF) return string(MIMETypeGIF)
case FormatAVIF: case FormatAVIF:
return string(MIMETypeAVIF) return string(MIMETypeAVIF)
case FormatJXL:
return string(MIMETypeJXL)
case FormatOriginal: case FormatOriginal:
// Original format passes content through unchanged. // Original format passes content through unchanged.
return mimeOctetStream return mimeOctetStream
+72
View File
@@ -0,0 +1,72 @@
package server
import (
"net/http"
"net/http/httptest"
"os"
"testing"
"github.com/davidbyttow/govips/v2/vips"
)
// TestImageProxyFlowJPEGXL requests the JPEG XL testdata/red.jxl, 64x48 and
// made with libvips, through pixa's router, upstream fetcher, image processor
// and disk cache: resized as PNG, resized as JPEG XL, and at its own size and
// format, which is JPEG XL. The second request for each URL is a cache hit.
func TestImageProxyFlowJPEGXL(t *testing.T) {
t.Parallel()
source, err := os.ReadFile("testdata/red.jxl")
if err != nil {
t.Fatalf("reading the test image: %v", err)
}
upstream := httptest.NewServer(http.HandlerFunc(
func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "image/jxl")
_, _ = w.Write(source)
}))
t.Cleanup(upstream.Close)
s, _, _ := startImageProxy(t, upstream)
tests := []struct {
sizeFormat string // the <size>.<format> part of the image URL
contentType string
imageType vips.ImageType
width, height int
}{
{"32x24.png", "image/png", vips.ImageTypePNG, 32, 24},
{"32x24.jxl", "image/jxl", vips.ImageTypeJXL, 32, 24},
{"orig.orig", "image/jxl", vips.ImageTypeJXL, 64, 48},
}
for _, tt := range tests {
target := "/v1/image/" + upstreamHost + "/red.jxl/" + tt.sizeFormat
for _, wantCache := range []string{"MISS", "HIT"} {
rec := getImage(t, s, target)
gotType := rec.Header().Get("Content-Type")
gotCache := rec.Header().Get("X-Pixa-Cache")
if gotType != tt.contentType || gotCache != wantCache {
t.Errorf("%s: %s, X-Pixa-Cache %s, want %s, %s",
target, gotType, gotCache, tt.contentType, wantCache)
}
img, err := vips.NewImageFromBuffer(rec.Body.Bytes())
if err != nil {
t.Fatalf("%s: loading the image: %v", target, err)
}
if img.Format() != tt.imageType ||
img.Width() != tt.width || img.Height() != tt.height {
t.Errorf("%s: %s %dx%d, want %s %dx%d", target,
vips.ImageTypes[img.Format()], img.Width(), img.Height(),
vips.ImageTypes[tt.imageType], tt.width, tt.height)
}
img.Close()
}
}
}
Binary file not shown.
+2 -1
View File
@@ -95,11 +95,12 @@
</label> </label>
<select id="format" name="format"> <select id="format" name="format">
<option value="orig" {{if eq .FormFormat "orig"}}selected{{end}}>Original</option> <option value="orig" {{if eq .FormFormat "orig"}}selected{{end}}>Original</option>
<option value="auto" {{if eq .FormFormat "auto"}}selected{{end}}>Auto (AVIF, WebP or JPEG)</option> <option value="auto" {{if eq .FormFormat "auto"}}selected{{end}}>Auto (JPEG XL, AVIF, WebP or JPEG)</option>
<option value="jpeg" {{if eq .FormFormat "jpeg"}}selected{{end}}>JPEG</option> <option value="jpeg" {{if eq .FormFormat "jpeg"}}selected{{end}}>JPEG</option>
<option value="png" {{if eq .FormFormat "png"}}selected{{end}}>PNG</option> <option value="png" {{if eq .FormFormat "png"}}selected{{end}}>PNG</option>
<option value="webp" {{if eq .FormFormat "webp"}}selected{{end}}>WebP</option> <option value="webp" {{if eq .FormFormat "webp"}}selected{{end}}>WebP</option>
<option value="avif" {{if eq .FormFormat "avif"}}selected{{end}}>AVIF</option> <option value="avif" {{if eq .FormFormat "avif"}}selected{{end}}>AVIF</option>
<option value="jxl" {{if eq .FormFormat "jxl"}}selected{{end}}>JPEG XL</option>
<option value="gif" {{if eq .FormFormat "gif"}}selected{{end}}>GIF</option> <option value="gif" {{if eq .FormFormat "gif"}}selected{{end}}>GIF</option>
</select> </select>
</div> </div>