From dc3667ba6267b6056d7639a582ea29752bfb1b54 Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Thu, 8 Oct 2026 04:38:18 +0000 Subject: [PATCH] Serve and accept JPEG XL as an image format (part of #222) 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 is turned into a distance, keeping 100 lossy. govips cannot have libvips strip metadata from JPEG XL, so it is removed from the image before saving; libvips 8.16 and later still add an EXIF block of their own, with nothing from the source. The conversion to sRGB moved ahead of the format switch, and a CMYK image with no ICC profile is converted to sRGB too, as libvips cannot save CMYK as JPEG XL. Model: opus-5-5 --- README.md | 34 ++- TODO.md | 16 ++ internal/handlers/format_auto.go | 18 +- internal/handlers/jpegxl_internal_test.go | 73 ++++++ internal/httpfetcher/httpfetcher.go | 2 + internal/httpfetcher/jpegxl_internal_test.go | 20 ++ internal/httpfetcher/mock.go | 2 + internal/imageprocessor/imageprocessor.go | 95 +++++++- .../jpegxl_output_internal_test.go | 220 ++++++++++++++++++ internal/imageprocessor/testdata/cmyk.jpg | Bin 0 -> 352 bytes internal/imgcache/imgcache.go | 7 +- internal/imgcache/urlparser.go | 2 + internal/magic/jpegxl_internal_test.go | 58 +++++ internal/magic/magic.go | 20 +- internal/server/jpegxl_internal_test.go | 72 ++++++ internal/server/testdata/red.jxl | Bin 0 -> 74 bytes internal/templates/generator.html | 3 +- 17 files changed, 606 insertions(+), 36 deletions(-) create mode 100644 internal/handlers/jpegxl_internal_test.go create mode 100644 internal/httpfetcher/jpegxl_internal_test.go create mode 100644 internal/imageprocessor/jpegxl_output_internal_test.go create mode 100644 internal/imageprocessor/testdata/cmyk.jpg create mode 100644 internal/magic/jpegxl_internal_test.go create mode 100644 internal/server/jpegxl_internal_test.go create mode 100644 internal/server/testdata/red.jxl diff --git a/README.md b/README.md index f8e7d92..de89834 100644 --- a/README.md +++ b/README.md @@ -245,7 +245,7 @@ A request whose query string cannot be decoded, or gives any parameter more than once, is refused with 400. - ``: one of `orig` (or `original`), `jpeg` (or `jpg`), `png`, `webp`, - `avif`, `gif`, or `auto` (below) + `avif`, `jxl` (JPEG XL), `gif`, or `auto` (below) - ``: `orig` or `x` (e.g. `800x600`) - `sig` and `exp`: the signature and its expiry, needed unless the host is 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 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 `Accept` header, in this order: -1. AVIF, when the header names `image/avif`; -2. WebP, when it names `image/webp`; -3. JPEG, when the first of `image/jpeg`, `image/*` and `*/*` that it names +1. JPEG XL, when the header names `image/jxl`; +2. AVIF, when it names `image/avif`; +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. 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 -`image/*` and `*/*`. pixa never sends a format the client refused: when the -header allows none of the three, the answer is 406, and a header that does not -parse, or has a `q` that is not a number from 0 to 1, is refused with 400. The -signature, or the token of an encrypted URL, covers `auto` itself, so one URL -serves every client. Each format chosen is cached as a separate image, and every -answer that depends on `Accept` (the image, a 304, and the 400 and 406 above) -carries `Vary: Accept`, so a shared cache keeps the formats apart too. +order. JPEG XL, AVIF and WebP must be named, as clients that cannot show them +also send `image/*` and `*/*`. pixa never sends a format the client refused: +when the header allows none of the four, the answer is 406, and a header that +does not parse, or has a `q` that is not a number from 0 to 1, is refused +with 400. The signature, or the token of an encrypted URL, covers `auto` itself, +so one URL serves every client. Each format chosen is cached as a separate +image, and every answer that depends on `Accept` (the image, a 304, and the 400 +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=, immutable`. 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 image with no profile as sRGB. Colours outside sRGB, such as the most 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, with its orientation, resolution, + size and colour space and fixed defaults, but nothing from the source's EXIF, + as govips cannot ask libvips to leave it out. ### Source Hosts diff --git a/TODO.md b/TODO.md index 4e2b832..4379289 100644 --- a/TODO.md +++ b/TODO.md @@ -30,6 +30,22 @@ P2: security: per-IP rate limiting on the image routes # 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 (orientation, + resolution, size, colour space, fixed defaults), with 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 requests no longer wait behind eviction queries that read a whole table (closes #227): the new `cache_usage` table holds the total cache usage, kept up to date by triggers on `source_content` and `variant_content` in the diff --git a/internal/handlers/format_auto.go b/internal/handlers/format_auto.go index 485faed..bca3bde 100644 --- a/internal/handlers/format_auto.go +++ b/internal/handlers/format_auto.go @@ -14,8 +14,8 @@ import ( // Errors for an Accept header that an auto URL cannot be served for. var ( errInvalidAccept = errors.New("invalid Accept header") - errNotAcceptable = errors.New( - "not acceptable: auto serves image/avif, image/webp or image/jpeg") + errNotAcceptable = errors.New("not acceptable: auto serves " + + "image/jxl, image/avif, image/webp or image/jpeg") ) // 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 -// header accept: AVIF when it names image/avif, else WebP when it names -// image/webp, else JPEG when its most specific entry of image/jpeg, image/* -// and */* allows it, or when it names nothing. A q of 0 refuses a format. -// AVIF and WebP must be named, as clients that cannot show them send image/* -// and */* too. +// header accept: JPEG XL when it names image/jxl, else AVIF when it names +// image/avif, else WebP when it names image/webp, else JPEG when its most +// specific entry of image/jpeg, image/* and */* allows it, or when it names +// nothing. A q of 0 refuses a format. JPEG XL, AVIF and WebP must be named, as +// clients that cannot show them send image/* and */* too. func formatForAccept(accept string) (imgcache.ImageFormat, error) { qualities, err := parseAccept(accept) if err != nil { @@ -66,6 +66,10 @@ func formatForAccept(accept string) (imgcache.ImageFormat, error) { return imgcache.FormatJPEG, nil } + if qualities["image/jxl"] > 0 { + return imgcache.FormatJXL, nil + } + if qualities["image/avif"] > 0 { return imgcache.FormatAVIF, nil } diff --git a/internal/handlers/jpegxl_internal_test.go b/internal/handlers/jpegxl_internal_test.go new file mode 100644 index 0000000..68bbe14 --- /dev/null +++ b/internal/handlers/jpegxl_internal_test.go @@ -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]) + } + } +} diff --git a/internal/httpfetcher/httpfetcher.go b/internal/httpfetcher/httpfetcher.go index 5640cf0..dd37489 100644 --- a/internal/httpfetcher/httpfetcher.go +++ b/internal/httpfetcher/httpfetcher.go @@ -45,6 +45,7 @@ const ( contentTypeGIF = "image/gif" contentTypeWebP = "image/webp" contentTypeAVIF = "image/avif" + contentTypeJXL = "image/jxl" contentTypeSVG = "image/svg+xml" contentTypeOctetStream = "application/octet-stream" ) @@ -156,6 +157,7 @@ func DefaultConfig() *Config { contentTypeGIF, contentTypeWebP, contentTypeAVIF, + contentTypeJXL, contentTypeSVG, }, AllowHTTP: false, diff --git a/internal/httpfetcher/jpegxl_internal_test.go b/internal/httpfetcher/jpegxl_internal_test.go new file mode 100644 index 0000000..8be65ec --- /dev/null +++ b/internal/httpfetcher/jpegxl_internal_test.go @@ -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) + } +} diff --git a/internal/httpfetcher/mock.go b/internal/httpfetcher/mock.go index 5dca6d8..2a3b11b 100644 --- a/internal/httpfetcher/mock.go +++ b/internal/httpfetcher/mock.go @@ -109,6 +109,8 @@ func detectContentTypeFromPath(path string) string { return contentTypeWebP case strings.HasSuffix(path, ".avif"): return contentTypeAVIF + case strings.HasSuffix(path, ".jxl"): + return contentTypeJXL case strings.HasSuffix(path, ".svg"): return contentTypeSVG default: diff --git a/internal/imageprocessor/imageprocessor.go b/internal/imageprocessor/imageprocessor.go index b7c2718..1ef818c 100644 --- a/internal/imageprocessor/imageprocessor.go +++ b/internal/imageprocessor/imageprocessor.go @@ -65,6 +65,7 @@ const ( FormatPNG Format = "png" FormatWebP Format = "webp" FormatAVIF Format = "avif" + FormatJXL Format = "jxl" FormatGIF Format = "gif" ) @@ -305,6 +306,7 @@ const ( mimeGIF = "image/gif" mimeWebP = "image/webp" mimeAVIF = "image/avif" + mimeJXL = "image/jxl" ) // SupportedInputFormats returns MIME types this processor can read. @@ -315,6 +317,7 @@ func (p *ImageProcessor) SupportedInputFormats() []string { mimeGIF, mimeWebP, mimeAVIF, + mimeJXL, } } @@ -326,6 +329,7 @@ func (p *ImageProcessor) SupportedOutputFormats() []Format { FormatGIF, FormatWebP, FormatAVIF, + FormatJXL, } } @@ -342,6 +346,8 @@ func FormatToMIME(format Format) string { return mimeGIF case FormatAVIF: return mimeAVIF + case FormatJXL: + return mimeJXL case FormatOriginal: return "application/octet-stream" default: @@ -411,9 +417,11 @@ func (p *ImageProcessor) detectFormat(img *vips.ImageRef) string { return "webp" case vips.ImageTypeAVIF, vips.ImageTypeHEIF: return string(FormatAVIF) + case vips.ImageTypeJXL: + return string(FormatJXL) case vips.ImageTypeUnknown, vips.ImageTypeMagick, vips.ImageTypePDF, vips.ImageTypeSVG, vips.ImageTypeTIFF, vips.ImageTypeBMP, - vips.ImageTypeJP2K, vips.ImageTypeJXL: + vips.ImageTypeJP2K: return "unknown" default: return "unknown" @@ -485,6 +493,17 @@ func (p *ImageProcessor) encode( 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 switch format { @@ -516,6 +535,9 @@ func (p *ImageProcessor) encode( Quality: quality, } + case FormatJXL: + return exportJXL(img, quality) + case FormatOriginal: return nil, fmt.Errorf("%w: %s", ErrUnsupportedOutputFormat, format) @@ -523,17 +545,6 @@ func (p *ImageProcessor) encode( 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 // GIF, which carries none of them. params.StripMetadata = true @@ -546,6 +557,64 @@ func (p *ImageProcessor) encode( return output, nil } +// 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. libvips 8.16 and later still write an + // EXIF block of their own: orientation, resolution, size, colour space + // and fixed defaults. + err := img.RemoveMetadata() + if err != nil { + return nil, err + } + + err = img.RemoveICCProfile() + if err != nil { + return nil, err + } + + params := vips.NewJxlExportParams() + params.Quality = quality + params.Distance = jxlDistance(quality) + + output, _, err := img.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. func (p *ImageProcessor) formatFromString(format string) Format { switch format { @@ -559,6 +628,8 @@ func (p *ImageProcessor) formatFromString(format string) Format { return FormatWebP case string(FormatAVIF): return FormatAVIF + case string(FormatJXL): + return FormatJXL default: return FormatJPEG } diff --git a/internal/imageprocessor/jpegxl_output_internal_test.go b/internal/imageprocessor/jpegxl_output_internal_test.go new file mode 100644 index 0000000..a7d5c61 --- /dev/null +++ b/internal/imageprocessor/jpegxl_output_internal_test.go @@ -0,0 +1,220 @@ +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_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) + } + } +} diff --git a/internal/imageprocessor/testdata/cmyk.jpg b/internal/imageprocessor/testdata/cmyk.jpg new file mode 100644 index 0000000000000000000000000000000000000000..ba18a4e94d7adfef6f12579a69f9368d7d9414df GIT binary patch literal 352 zcmex=(iUwW$pkka<)WpdpCN3c zY31zV>gMj@=@lFj8WtWA8I_!pnwFlCnN?g;T2@|BS=HRq+ScCD*)?hMl&RCE&zL!D z(c&dbmn~nha@D5ITefc7zGLUELx+zXJ$C%W$y1juU%7hi`i+~n9zJ^f. 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() + } + } +} diff --git a/internal/server/testdata/red.jxl b/internal/server/testdata/red.jxl new file mode 100644 index 0000000000000000000000000000000000000000..9429f50184397295744c4db41e7ec530b0e96f54 GIT binary patch literal 74 zcmV-Q0JZ=B3d;ro3<2{)699k;l~KkGUe5J=`JuyN+QxN;w%WF^gt6IP7$gM1fsx0% gc<~yCQUL<o9L?