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
245 lines
7.1 KiB
Go
245 lines
7.1 KiB
Go
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)
|
|
}
|
|
}
|
|
}
|