]> git.maquefel.me Git - brevno-suite/hugo/commitdiff
images: Add compression option to image config and clean up some of the options handling
authorBjørn Erik Pedersen <bjorn.erik.pedersen@gmail.com>
Mon, 29 Dec 2025 16:00:49 +0000 (17:00 +0100)
committerBjørn Erik Pedersen <bjorn.erik.pedersen@gmail.com>
Mon, 29 Dec 2025 20:38:19 +0000 (21:38 +0100)
The original idea of setting quality to 0 for lossless WebP was didn't allow a setting that made sense for both JPEG and WebP.

Note that the above didn't really work at all, so this should not break anything.

14 files changed:
internal/warpc/genwebp/webp.c
internal/warpc/warpc.go
internal/warpc/wasm/webp.wasm
internal/warpc/webp.go
resources/images/codec.go
resources/images/config.go
resources/images/config_test.go
resources/images/image.go
resources/images/images_golden_integration_test.go
resources/images/testdata/images_golden/losslessvsquality/resize-300x300-jpeg.jpg [new file with mode: 0644]
resources/images/testdata/images_golden/losslessvsquality/resize-300x300-webp-lossy.webp [new file with mode: 0644]
resources/images/testdata/images_golden/losslessvsquality/resize-300x300-webp.webp [new file with mode: 0644]
resources/images/testdata/images_golden/process/webp/resize-300x300-webp-lossless.webp [new file with mode: 0644]
resources/images/testdata/images_golden/process/webp/resize-300x300-webp-q0.webp [deleted file]

index 79dff6201e0723c02e46661b710901cc4a32a565..2a51fa07f1d35f8d97c78b297697e83400e7ea55 100644 (file)
@@ -73,9 +73,10 @@ typedef struct
 
 typedef struct
 {
-    float quality; // between 0 and 100. Set to 0 for lossless.
-    char hint[64]; // drawing, icon, photo, picture, or text. Default is photo.
-    int preset;    // preset to use; resolved from hint.
+    float quality;        // between 1 and 100.
+    char compression[32]; // "lossy" or "lossless"
+    char hint[64];        // drawing, icon, photo, picture, or text. Default is photo.
+    int preset;           // preset to use; resolved from hint.
 
     bool useSharpYuv; // use sharp YUV for better quality.
 
@@ -302,7 +303,7 @@ static uint8_t initEncoderConfig(WebPConfig *config, InputOptions opts)
         return 0;
     }
 
-    if (opts.quality == 0)
+    if (strcmp(opts.compression, "lossless") == 0)
     {
         // Activate the lossless compression mode with the desired efficiency level
         // between 0 (fastest, lowest compression) and 9 (slower, best compression).
@@ -396,6 +397,12 @@ InputMessage parse_input_message(const char *line)
         if (options_object != NULL)
         {
             msg.data.options.quality = (int)json_object_get_number(options_object, "quality");
+            const char *compression_str = json_object_get_string(options_object, "compression");
+            if (compression_str != NULL)
+            {
+                strncpy(msg.data.options.compression, compression_str, sizeof(msg.data.options.compression) - 1);
+                msg.data.options.compression[sizeof(msg.data.options.compression) - 1] = '\0';
+            }
             const char *hint_str = json_object_get_string(options_object, "hint");
             if (hint_str != NULL)
             {
index c3bcca53c2c9d1c0927bf17950d8d86d1e504cc1..5f9797de1ad32b67b25842d2a146801b679897cf 100644 (file)
@@ -792,11 +792,9 @@ func (d *Dispatchers) Webp() (Dispatcher[WebpInput, WebpOutput], error) {
        return d.webp.start()
 }
 
-func (d *Dispatchers) NewWepCodec(quality int, hint string) (*WebpCodec, error) {
+func (d *Dispatchers) NewWepCodec() (*WebpCodec, error) {
        return &WebpCodec{
-               d:       d.Webp,
-               quality: quality,
-               hint:    hint,
+               d: d.Webp,
        }, nil
 }
 
index c99ae17cf78e98517e54ee601179f152fa2442df..14c4ff841ca053e062ab48eb87ed1826692e51ec 100755 (executable)
Binary files a/internal/warpc/wasm/webp.wasm and b/internal/warpc/wasm/webp.wasm differ
index fe0df8e30f9173d94147ff71fe56f61fb31199c0..ea83409227911020236cbb1dc4227e3a665132ea 100644 (file)
@@ -22,6 +22,7 @@ import (
        "image/color"
        "image/draw"
        "io"
+       "maps"
 
        "github.com/gohugoio/hugo/common/himage"
        "github.com/gohugoio/hugo/common/hugio"
@@ -82,9 +83,7 @@ type WebpOutput struct {
 }
 
 type WebpCodec struct {
-       d       func() (Dispatcher[WebpInput, WebpOutput], error)
-       quality int
-       hint    string
+       d func() (Dispatcher[WebpInput, WebpOutput], error)
 }
 
 // Decode reads a WEBP image from r and returns it as an image.Image.
@@ -201,11 +200,7 @@ func (d *WebpCodec) DecodeConfig(r io.Reader) (image.Config, error) {
        }, nil
 }
 
-func (d *WebpCodec) Encode(w io.Writer, img image.Image) error {
-       return d.EncodeOptions(w, img, nil)
-}
-
-func (d *WebpCodec) EncodeOptions(w io.Writer, img image.Image, options map[string]any) error {
+func (d *WebpCodec) Encode(w io.Writer, img image.Image, opts map[string]any) error {
        b := img.Bounds()
        if b.Dx() >= 1<<16 || b.Dy() >= 1<<16 {
                return errors.New("webp: image is too large to encode")
@@ -298,24 +293,8 @@ func (d *WebpCodec) EncodeOptions(w io.Writer, img image.Image, options map[stri
                return fmt.Errorf("no image bytes extracted from %T", img)
        }
 
-       // Commands:
-       // encodeNRGBA
-       // encodeGray
-       // decode
-       // config
-
-       opts := map[string]any{
-               "quality":     d.quality, // a number between 0 and 100. Set to 0 for lossless.
-               "hint":        d.hint,    // drawing, icon, photo, picture, or text
-               "useSharpYuv": true,      // Use sharp (and slow) RGB->YUV conversion.
-       }
-
-       // Override with per-image options if provided.
-       for _, key := range []string{"quality", "hint"} {
-               if v, ok := options[key]; ok {
-                       opts[key] = v
-               }
-       }
+       opts = maps.Clone(opts)
+       opts["useSharpYuv"] = true // Use sharp (and slow) RGB->YUV conversion.
 
        message := Message[WebpInput]{
                Header: Header{
index 087643c2650e54587e774bd3c841e3193e025e58..b36c1b15be81b1ee02a1ae7529badca0e38a4577 100644 (file)
@@ -37,35 +37,27 @@ type Decoder interface {
        DecodeConfig(r io.Reader) (image.Config, error)
 }
 
-// Encoder defines the encoding of an image format.
-type Encoder interface {
-       Encode(w io.Writer, src image.Image) error
-}
-
 type ToEncoder interface {
        EncodeTo(conf ImageConfig, w io.Writer, src image.Image) error
 }
 
-// EncoderWithOptions defines the encoding of an image format with options.
-// This is currently only used for WebP and the options are passed as a map
-// to match the internal WASM API. The options map may be nil when no options
-// need to be overridden.
-type EncoderWithOptions interface {
-       EncodeOptions(w io.Writer, src image.Image, options map[string]any) error
+// EncoderWithOptions defines the encoding of an image format with the given options.
+type Encoder interface {
+       Encode(w io.Writer, src image.Image, options map[string]any) error
 }
 
-// CodecStdlib defines both decoding and encoding of an image format as defined by the standard library.
-type CodecStdlib interface {
+// EncodeDecoder defines both decoding and encoding of an image format as defined by the standard library.
+type EncodeDecoder interface {
        Decoder
        Encoder
 }
 
 // Codec is a generic image codec supporting multiple formats.
 type Codec struct {
-       webp CodecStdlib
+       webp EncodeDecoder
 }
 
-func newCodec(webp CodecStdlib) *Codec {
+func newCodec(webp EncodeDecoder) *Codec {
        return &Codec{webp: webp}
 }
 
@@ -131,20 +123,12 @@ func (d *Codec) EncodeTo(conf ImageConfig, w io.Writer, img image.Image) error {
        case BMP:
                return bmp.Encode(w, img)
        case WEBP:
-               if enc, ok := d.webp.(EncoderWithOptions); ok {
-                       var opts map[string]any
-                       if conf.qualitySetForImage || conf.hintSetForImage {
-                               opts = make(map[string]any)
-                               if conf.qualitySetForImage {
-                                       opts["quality"] = conf.Quality
-                               }
-                               if conf.hintSetForImage {
-                                       opts["hint"] = conf.Hint
-                               }
-                       }
-                       return enc.EncodeOptions(w, img, opts)
+               opts := map[string]any{
+                       "compression": conf.Compression,
+                       "quality":     conf.Quality,
+                       "hint":        conf.Hint,
                }
-               return d.webp.Encode(w, img)
+               return d.webp.Encode(w, img, opts)
        default:
                return errors.New("format not supported")
        }
index 32940021ce7d1994727a1b9f0464d6128dc3bd6f..788d9c7b653ab0bd0157c10d7af4996ce9d044e5 100644 (file)
@@ -85,6 +85,11 @@ var anchorPositions = map[string]gift.Anchor{
        smartCropIdentifier:            SmartCropAnchor,
 }
 
+var compressionMethods = map[string]bool{
+       "lossy":    true,
+       "lossless": true,
+}
+
 // These encoding hints are currently only relevant for Webp.
 var hints = map[string]bool{
        "picture": true,
@@ -127,6 +132,7 @@ const (
        defaultResampleFilter = "box"
        defaultBgColor        = "#ffffff"
        defaultHint           = "photo"
+       defaultCompression    = "lossy"
 )
 
 var (
@@ -135,6 +141,7 @@ var (
                "bgColor":        defaultBgColor,
                "hint":           defaultHint,
                "quality":        defaultJPEGQuality,
+               "compression":    defaultCompression,
        }
 
        defaultImageConfig *config.ConfigNamespace[ImagingConfig, ImagingConfigInternal]
@@ -226,7 +233,8 @@ func DecodeImageConfig(options []string, defaults *config.ConfigNamespace[Imagin
                        c.Filter = filter
                } else if _, ok := hints[part]; ok {
                        c.Hint = part
-                       c.hintSetForImage = true
+               } else if _, ok := compressionMethods[part]; ok {
+                       c.Compression = part
                } else if part[0] == '#' {
                        c.BgColor, err = hexStringToColorGo(part[1:])
                        if err != nil {
@@ -237,10 +245,9 @@ func DecodeImageConfig(options []string, defaults *config.ConfigNamespace[Imagin
                        if err != nil {
                                return c, err
                        }
-                       if c.Quality < 0 || c.Quality > 100 {
-                               return c, errors.New("quality ranges from 0 to 100 inclusive")
+                       if c.Quality < 1 || c.Quality > 100 {
+                               return c, errors.New("quality ranges from 1 to 100 inclusive")
                        }
-                       c.qualitySetForImage = true
                } else if part[0] == 'r' {
                        c.Rotate, err = strconv.Atoi(part[1:])
                        if err != nil {
@@ -306,12 +313,16 @@ func DecodeImageConfig(options []string, defaults *config.ConfigNamespace[Imagin
                c.TargetFormat = sourceFormat
        }
 
-       if !c.qualitySetForImage && c.Quality <= 0 && c.TargetFormat.RequiresDefaultQuality() {
+       if c.Quality <= 0 && c.TargetFormat.RequiresDefaultQuality() {
                // We need a quality setting for all JPEGs and WEBPs,
-               // unless the user explicitly set quality (e.g., q0 for lossless WebP).
+               // unless the user explicitly set quality.
                c.Quality = defaults.Config.Imaging.Quality
        }
 
+       if c.Compression == "" {
+               c.Compression = defaults.Config.Imaging.Compression
+       }
+
        if c.BgColor == nil && c.TargetFormat != sourceFormat {
                if sourceFormat.SupportsTransparency() && !c.TargetFormat.SupportsTransparency() {
                        c.BgColor = defaults.Config.BgColor
@@ -341,11 +352,11 @@ type ImageConfig struct {
        // If set, this will be used as the key in filenames etc.
        Key string
 
-       // Quality ranges from 1 to 100 inclusive, higher is better.
+       // Quality ranges from 0 to 100 inclusive, higher is better.
        // This is only relevant for JPEG and WEBP images.
+       // For WebP, 0 means lossless.
        // Default is 75.
-       Quality            int
-       qualitySetForImage bool // Whether the above is set for this image.
+       Quality int
 
        // Rotate rotates an image by the given angle counter-clockwise.
        // The rotation will be performed first.
@@ -360,8 +371,9 @@ type ImageConfig struct {
 
        // Hint about what type of picture this is. Used to optimize encoding
        // when target is set to webp.
-       Hint            string
-       hintSetForImage bool // Whether the above is set for this image.
+       Hint string
+
+       Compression string
 
        Width  int
        Height int
@@ -415,6 +427,11 @@ type ImagingConfig struct {
        // Default image quality setting (1-100). Only used for JPEG and WebP images.
        Quality int
 
+       // Compression method to use.
+       // One of "lossy" or "lossless".
+       // Note that lossless is currently only supported for WebP.
+       Compression string
+
        // Resample filter to use in resize operations.
        ResampleFilter string
 
@@ -434,7 +451,7 @@ type ImagingConfig struct {
 }
 
 func (cfg *ImagingConfig) init() error {
-       if cfg.Quality < 0 || cfg.Quality > 100 {
+       if cfg.Quality < 1 || cfg.Quality > 100 {
                return errors.New("image quality must be a number between 1 and 100")
        }
 
@@ -442,6 +459,7 @@ func (cfg *ImagingConfig) init() error {
        cfg.Anchor = strings.ToLower(cfg.Anchor)
        cfg.ResampleFilter = strings.ToLower(cfg.ResampleFilter)
        cfg.Hint = strings.ToLower(cfg.Hint)
+       cfg.Compression = strings.ToLower(cfg.Compression)
 
        if cfg.Anchor == "" {
                cfg.Anchor = smartCropIdentifier
index 907bb10cb1acdc4cb7a214b5c9348e4ed5e054c5..ba4c7e158e509f05cda4daf6b9e4643387e73000 100644 (file)
@@ -133,7 +133,6 @@ func newImageConfig(action string, width, height, quality, rotate int, filter, a
        c.Width = width
        c.Height = height
        c.Quality = quality
-       c.qualitySetForImage = quality != 75
        c.Rotate = rotate
        c.BgColor, _ = hexStringToColorGo(bgColor)
        c.Anchor = SmartCropAnchor
index 4d8f4961c4465f344306b669363ff87e68393415..ba518841a1d7036975f9484211a0559b5d284fe3 100644 (file)
@@ -137,7 +137,7 @@ func NewImageProcessor(warnl logg.LevelLogger, wasmDispatchers *warpc.Dispatcher
                return nil, err
        }
 
-       webpCodec, err := wasmDispatchers.NewWepCodec(cfg.Config.Imaging.Quality, cfg.Config.Imaging.Hint)
+       webpCodec, err := wasmDispatchers.NewWepCodec()
        if err != nil {
                return nil, err
        }
@@ -300,9 +300,10 @@ func GetDefaultImageConfig(defaults *config.ConfigNamespace[ImagingConfig, Imagi
                defaults = defaultImageConfig
        }
        return ImageConfig{
-               Anchor:  -1, // The real values start at 0.
-               Hint:    "photo",
-               Quality: defaults.Config.Imaging.Quality,
+               Anchor:      -1, // The real values start at 0.
+               Hint:        "photo",
+               Quality:     defaults.Config.Imaging.Quality,
+               Compression: defaults.Config.Imaging.Compression,
        }
 }
 
index d5a70702404f09c4d74cdc59831d6477327db0d9..a3976d56762be107fbfbd6fdc27168d1f2bfa8a4 100644 (file)
@@ -21,6 +21,17 @@ import (
        "github.com/gohugoio/hugo/resources/images/imagetesting"
 )
 
+const goldenProcess = `
+{{ define "process"}}
+{{ $img := .img.Process .spec }}
+{{ $ext := path.Ext $img.RelPermalink }}
+{{ $name := printf "images/%s%s" (.spec | anchorize) $ext  }}
+{{ with $img | resources.Copy $name }}
+{{ .Publish }}
+{{ end }}
+{{ end }}
+`
+
 // Note, if you're enabling writeGoldenFiles on a MacOS ARM 64 you need to run the test with GOARCH=amd64, e.g.
 func TestImagesGoldenFiltersMisc(t *testing.T) {
        t.Parallel()
@@ -324,15 +335,8 @@ Home.
 {{ template "process" (dict "spec" "resize 100x100 r180" "img" $gopher) }}
 {{ template "process" (dict "spec" "resize 300x300 jpg #b31280" "img" $gopher) }}
 
-{{ define "process"}}
-{{ $img := .img.Process .spec }}
-{{ $ext := path.Ext $img.RelPermalink }}
-{{ $name := printf "images/%s%s" (.spec | anchorize) $ext  }}
-{{ with $img | resources.Copy $name }}
-{{ .Publish }}
-{{ end }}
-{{ end }}
-`
+
+` + goldenProcess
 
        opts := imagetesting.DefaultGoldenOpts
        opts.T = t
@@ -378,7 +382,7 @@ Home.
 {{/* These are sorted. The end file name will be created from the spec + extension, so make sure these are unique. */}}
 {{ template "process" (dict "spec" "crop 300x300 gif" "img" $animWebp) }}
 {{ template "process" (dict "spec" "crop 300x300 smart" "img" $fuzzyCircle) }}
- {{ template "process" (dict "spec" "crop 300x300 smart #ff9999" "img" $fuzzyCircle) }}
+{{ template "process" (dict "spec" "crop 300x300 smart #ff9999" "img" $fuzzyCircle) }}
 {{ template "process" (dict "spec" "crop 300x300" "img" $animWebp) }}
 {{ template "process" (dict "spec" "crop 500x200 smart webp" "img" $sunset) }}
 {{ template "process" (dict "spec" "crop 500x200 smart webp" "img" $sunset) }}
@@ -388,7 +392,7 @@ Home.
 {{ template "process" (dict "spec" "png" "img" $highContrast) }}
 {{ template "process" (dict "spec" "resize 300x300" "img" $giphy) }}
 {{ template "process" (dict "spec" "resize 300x300 webp" "img" $giphy) }}
-{{ template "process" (dict "spec" "resize 300x300 webp q0" "img" $sunset) }}
+{{ template "process" (dict "spec" "resize 300x300 webp lossless" "img" $sunset) }}
 {{ template "process" (dict "spec" "resize 300x300 webp q1" "img" $sunset) }}
 {{ template "process" (dict "spec" "resize 300x300 webp q33" "img" $sunset) }}
 {{ template "process" (dict "spec" "resize 300x300 webp q75" "img" $sunset) }}
@@ -486,7 +490,7 @@ Home.
 {{ template "invoke" (dict "copyFormat" "jpg" "base" $sunset "method" "fit"  "spec" "200x200" ) }}
 {{ template "invoke" (dict "copyFormat" "jpg" "base" $sunset "method" "crop"  "spec" "200x200" ) }}
 {{ template "invoke" (dict "copyFormat" "jpg" "base" $sunset "method" "crop"  "spec" "350x400 center" ) }}
- {{ template "invoke" (dict "copyFormat" "jpg" "base" $sunset "method" "crop"  "spec" "350x400 smart" ) }}
+{{ template "invoke" (dict "copyFormat" "jpg" "base" $sunset "method" "crop"  "spec" "350x400 smart" ) }}
 {{ template "invoke" (dict "copyFormat" "jpg" "base" $sunset "method" "crop"  "spec" "350x400 center r90" ) }}
 {{ template "invoke" (dict "copyFormat" "jpg" "base" $sunset "method" "crop"  "spec" "350x400 center q20" ) }}
 {{ template "invoke" (dict "copyFormat" "png" "base" $gopher "method" "resize"  "spec" "100x" ) }}
@@ -521,3 +525,37 @@ Home.
 
        imagetesting.RunGolden(opts)
 }
+
+func TestImagesGoldenConfigLossyVsQuality(t *testing.T) {
+       t.Parallel()
+
+       if imagetesting.SkipGoldenTests {
+               t.Skip("Skip golden test on this architecture")
+       }
+
+       files := `
+-- hugo.toml --
+[imaging]
+quality = 90 # will only apply to jpeg in this setup.
+compression = "lossless" # for webp
+-- assets/sunset.jpg --
+sourcefilename: ../testdata/sunset.jpg
+-- layouts/home.html --
+Home.
+{{ $sunset := resources.Get "sunset.jpg" }}
+{{ template "process" (dict "spec" "resize 300x300 webp" "img" $sunset) }}
+{{ template "process" (dict "spec" "resize 300x300 webp lossy" "img" $sunset) }}
+{{ template "process" (dict "spec" "resize 300x300 jpeg" "img" $sunset) }}
+
+` + goldenProcess
+
+       // Will be used as the base folder for generated images.
+       name := "losslessvsquality"
+
+       opts := imagetesting.DefaultGoldenOpts
+       opts.T = t
+       opts.Name = name
+       opts.Files = files
+
+       imagetesting.RunGolden(opts)
+}
diff --git a/resources/images/testdata/images_golden/losslessvsquality/resize-300x300-jpeg.jpg b/resources/images/testdata/images_golden/losslessvsquality/resize-300x300-jpeg.jpg
new file mode 100644 (file)
index 0000000..08fbff9
Binary files /dev/null and b/resources/images/testdata/images_golden/losslessvsquality/resize-300x300-jpeg.jpg differ
diff --git a/resources/images/testdata/images_golden/losslessvsquality/resize-300x300-webp-lossy.webp b/resources/images/testdata/images_golden/losslessvsquality/resize-300x300-webp-lossy.webp
new file mode 100644 (file)
index 0000000..2e648b7
Binary files /dev/null and b/resources/images/testdata/images_golden/losslessvsquality/resize-300x300-webp-lossy.webp differ
diff --git a/resources/images/testdata/images_golden/losslessvsquality/resize-300x300-webp.webp b/resources/images/testdata/images_golden/losslessvsquality/resize-300x300-webp.webp
new file mode 100644 (file)
index 0000000..ee53c60
Binary files /dev/null and b/resources/images/testdata/images_golden/losslessvsquality/resize-300x300-webp.webp differ
diff --git a/resources/images/testdata/images_golden/process/webp/resize-300x300-webp-lossless.webp b/resources/images/testdata/images_golden/process/webp/resize-300x300-webp-lossless.webp
new file mode 100644 (file)
index 0000000..ee53c60
Binary files /dev/null and b/resources/images/testdata/images_golden/process/webp/resize-300x300-webp-lossless.webp differ
diff --git a/resources/images/testdata/images_golden/process/webp/resize-300x300-webp-q0.webp b/resources/images/testdata/images_golden/process/webp/resize-300x300-webp-q0.webp
deleted file mode 100644 (file)
index ee53c60..0000000
Binary files a/resources/images/testdata/images_golden/process/webp/resize-300x300-webp-q0.webp and /dev/null differ