From: Bjørn Erik Pedersen Date: Mon, 29 Dec 2025 16:00:49 +0000 (+0100) Subject: images: Add compression option to image config and clean up some of the options handling X-Git-Url: http://git.maquefel.me/?a=commitdiff_plain;h=c6ae33c61d34d702584f532c50962023394697f5;p=brevno-suite%2Fhugo images: Add compression option to image config and clean up some of the options handling 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. --- diff --git a/internal/warpc/genwebp/webp.c b/internal/warpc/genwebp/webp.c index 79dff6201..2a51fa07f 100644 --- a/internal/warpc/genwebp/webp.c +++ b/internal/warpc/genwebp/webp.c @@ -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) { diff --git a/internal/warpc/warpc.go b/internal/warpc/warpc.go index c3bcca53c..5f9797de1 100644 --- a/internal/warpc/warpc.go +++ b/internal/warpc/warpc.go @@ -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 } diff --git a/internal/warpc/wasm/webp.wasm b/internal/warpc/wasm/webp.wasm index c99ae17cf..14c4ff841 100755 Binary files a/internal/warpc/wasm/webp.wasm and b/internal/warpc/wasm/webp.wasm differ diff --git a/internal/warpc/webp.go b/internal/warpc/webp.go index fe0df8e30..ea8340922 100644 --- a/internal/warpc/webp.go +++ b/internal/warpc/webp.go @@ -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{ diff --git a/resources/images/codec.go b/resources/images/codec.go index 087643c26..b36c1b15b 100644 --- a/resources/images/codec.go +++ b/resources/images/codec.go @@ -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") } diff --git a/resources/images/config.go b/resources/images/config.go index 32940021c..788d9c7b6 100644 --- a/resources/images/config.go +++ b/resources/images/config.go @@ -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 diff --git a/resources/images/config_test.go b/resources/images/config_test.go index 907bb10cb..ba4c7e158 100644 --- a/resources/images/config_test.go +++ b/resources/images/config_test.go @@ -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 diff --git a/resources/images/image.go b/resources/images/image.go index 4d8f4961c..ba518841a 100644 --- a/resources/images/image.go +++ b/resources/images/image.go @@ -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, } } diff --git a/resources/images/images_golden_integration_test.go b/resources/images/images_golden_integration_test.go index d5a707024..a3976d567 100644 --- a/resources/images/images_golden_integration_test.go +++ b/resources/images/images_golden_integration_test.go @@ -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 index 000000000..08fbff97c 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 index 000000000..2e648b7be 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 index 000000000..ee53c6030 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 index 000000000..ee53c6030 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 index ee53c6030..000000000 Binary files a/resources/images/testdata/images_golden/process/webp/resize-300x300-webp-q0.webp and /dev/null differ