]> git.maquefel.me Git - brevno-suite/hugo/commitdiff
Add AVIF, HEIF and HEIC partial support (only metadata for now)
authorBjørn Erik Pedersen <bjorn.erik.pedersen@gmail.com>
Tue, 24 Feb 2026 18:45:12 +0000 (19:45 +0100)
committerGitHub <noreply@github.com>
Tue, 24 Feb 2026 18:45:12 +0000 (19:45 +0100)
* Add AVIF, HEIF and HEIC partial support

* Add them as media types.
* Support reading metadata (Width, Height, Exif, etc.) from these formats.
* Add a new template function IsImageResourceMeta to check if a resource supports image metadata operations, which will return true for AVIF, HEIF and HEIC resources even if they don't support full image operations yet.

Fixes #14549

14 files changed:
common/hugio/readers.go
hugolib/integrationtest_builder.go
media/builtin.go
media/config_test.go
resources/images/codec.go
resources/images/config.go
resources/images/image.go
resources/images/meta/meta_integration_test.go
resources/resource_spec.go
resources/testdata/sunset.avif [new file with mode: 0644]
resources/transform.go
tpl/images/images.go
tpl/reflect/reflect.go
tpl/reflect/reflect_integration_test.go

index aba54437a33c295576953565bdb5205907db3ec1..7e50e6a5e19b934c68aa39d027ff76f1944cd10d 100644 (file)
@@ -19,16 +19,10 @@ import (
        "strings"
 )
 
-// ReadSeeker wraps io.Reader and io.Seeker.
-type ReadSeeker interface {
-       io.Reader
-       io.Seeker
-}
-
 // ReadSeekCloser is implemented by afero.File. We use this as the common type for
 // content in Resource objects, even for strings.
 type ReadSeekCloser interface {
-       ReadSeeker
+       io.ReadSeeker
        io.Closer
 }
 
@@ -70,7 +64,7 @@ type ReadSeekCloserProvider interface {
 
 // readSeekerNopCloser implements ReadSeekCloser by doing nothing in Close.
 type readSeekerNopCloser struct {
-       ReadSeeker
+       io.ReadSeeker
 }
 
 // Close does nothing.
@@ -79,7 +73,7 @@ func (r readSeekerNopCloser) Close() error {
 }
 
 // NewReadSeekerNoOpCloser creates a new ReadSeekerNoOpCloser with the given ReadSeeker.
-func NewReadSeekerNoOpCloser(r ReadSeeker) ReadSeekCloser {
+func NewReadSeekerNoOpCloser(r io.ReadSeeker) ReadSeekCloser {
        return readSeekerNopCloser{r}
 }
 
@@ -111,6 +105,22 @@ func NewReadSeekerNoOpCloserFromBytes(content []byte) readSeekerNopCloser {
        return readSeekerNopCloser{bytes.NewReader(content)}
 }
 
+// NewReadSeekerNoOpCloserFromReader creates a new ReadSeekerNoOpCloser from the given io.Reader.
+// If the given io.Reader is not an io.ReadSeeker, the entire content will be read into memory.
+func NewReadSeekerNoOpCloserFromReader(r io.Reader) (readSeekerNopCloser, error) {
+       var rs io.ReadSeeker
+       if s, ok := r.(io.ReadSeeker); ok {
+               rs = s
+       } else {
+               b, err := io.ReadAll(r)
+               if err != nil {
+                       return readSeekerNopCloser{rs}, err
+               }
+               rs = bytes.NewReader(b)
+       }
+       return readSeekerNopCloser{rs}, nil
+}
+
 // NewOpenReadSeekCloser creates a new ReadSeekCloser from the given ReadSeeker.
 // The ReadSeeker will be seeked to the beginning before returned.
 func NewOpenReadSeekCloser(r ReadSeekCloser) OpenReadSeekCloser {
index 4628d5f6f32ab3fc0e62e760a54f626463db6888..ed558cbaf66b6a2a0ccc62ec50f1e0fbee15d757 100644 (file)
@@ -506,7 +506,7 @@ func (s *IntegrationTestBuilder) ImageHelper(filename string) *IntegrationTestIm
        fs := s.fs.WorkingDirReadOnly
        b, err := afero.ReadFile(fs, filename)
        s.Assert(err, qt.IsNil)
-       conf, format, err := s.H.ResourceSpec.Imaging.Codec.DecodeConfig(bytes.NewReader(b))
+       conf, format, err := s.H.ResourceSpec.Imaging.Codec.DecodeConfig(0, bytes.NewReader(b))
        s.Assert(err, qt.IsNil)
        img, err := s.H.ResourceSpec.Imaging.Codec.Decode(bytes.NewReader(b))
        s.Assert(err, qt.IsNil)
index 41d1ed655a866639c7b0f73bed17913ada2faf7e..80c4df6fdec764f1893aba3036a7f980720bb276 100644 (file)
@@ -29,6 +29,9 @@ type BuiltinTypes struct {
        TIFFType Type
        BMPType  Type
        WEBPType Type
+       AVIFType Type
+       HEIFType Type
+       HEICType Type
 
        // Common font types
        TrueTypeFontType Type
@@ -85,6 +88,9 @@ var Builtin = BuiltinTypes{
        TIFFType: Type{Type: "image/tiff"},
        BMPType:  Type{Type: "image/bmp"},
        WEBPType: Type{Type: "image/webp"},
+       AVIFType: Type{Type: "image/avif"},
+       HEIFType: Type{Type: "image/heif"},
+       HEICType: Type{Type: "image/heic"},
 
        // Common font types
        TrueTypeFontType: Type{Type: "font/ttf"},
@@ -141,6 +147,9 @@ var defaultMediaTypesConfig = map[string]any{
        "image/tiff": map[string]any{"suffixes": []string{"tif", "tiff"}},
        "image/bmp":  map[string]any{"suffixes": []string{"bmp"}},
        "image/webp": map[string]any{"suffixes": []string{"webp"}},
+       "image/avif": map[string]any{"suffixes": []string{"avif"}},
+       "image/heif": map[string]any{"suffixes": []string{"heif"}},
+       "image/heic": map[string]any{"suffixes": []string{"heic"}},
 
        // Common font types
        "font/ttf": map[string]any{"suffixes": []string{"ttf"}},
index 5abbcac2f2e5a902b6ee6e52abcde40338325b56..6e626ba64f43238cdb50d68e2f88157896f134a3 100644 (file)
@@ -151,5 +151,5 @@ func TestDefaultTypes(t *testing.T) {
 
        }
 
-       c.Assert(len(DefaultTypes), qt.Equals, 41)
+       c.Assert(len(DefaultTypes), qt.Equals, 44)
 }
index 3b1e0f3832270733173f0cf9fb9c36a9e07694cf..abc10ca99a00f219fecfc9de1e0afc66915d8480 100644 (file)
@@ -25,10 +25,12 @@ import (
        "image/jpeg"
        "image/png"
        "io"
+       "strings"
 
        "github.com/bep/imagemeta"
        "github.com/bep/logg"
        "github.com/gohugoio/hugo/common/himage"
+       "github.com/gohugoio/hugo/common/hugio"
        "golang.org/x/image/bmp"
        "golang.org/x/image/tiff"
 )
@@ -230,41 +232,44 @@ func (d *Codec) Decode(r io.Reader) (image.Image, error) {
        return img, err
 }
 
-func (d *Codec) DecodeConfig(r io.Reader) (image.Config, string, error) {
+func (d *Codec) DecodeConfig(f Format, r io.Reader) (image.Config, string, error) {
        rr := toPeekReader(r)
        format, err := formatFromImage(rr)
        if err != nil {
                return image.Config{}, "", err
        }
+       if format == 0 {
+               format = f
+       }
        r = rr
-       if format == WEBP {
-               if rs, ok := r.(io.ReadSeeker); ok {
-                       rs.Seek(0, 0)
-                       // Avoid spinning up a WASM runtime if we don't have to.
-                       res, err := imagemeta.Decode(
-                               imagemeta.Options{
-                                       R:           rs,
-                                       ImageFormat: imagemeta.WebP,
-                                       Sources:     imagemeta.CONFIG,
-                               },
-                       )
-                       if err == nil {
-                               return image.Config{
-                                       Width:      res.ImageConfig.Width,
-                                       Height:     res.ImageConfig.Height,
-                                       ColorModel: color.RGBAModel,
-                               }, "webp", nil
-                       }
-                       rs.Seek(0, 0)
-                       r = rs
+       if format.UseImageMetaConfigDecoder() {
+               rs, err := hugio.NewReadSeekerNoOpCloserFromReader(r)
+               if err != nil {
+                       return image.Config{}, "", err
                }
-               // Fallback to the webp codec config decode.
-               cfg, err := d.webp.DecodeConfig(r)
-               return cfg, "webp", err
+               rs.Seek(0, 0)
+               res, err := imagemeta.Decode(
+                       imagemeta.Options{
+                               R:           rs,
+                               ImageFormat: format.ToImageMetaImageFormatFormat(),
+                               Sources:     imagemeta.CONFIG,
+                       },
+               )
+               if err == nil {
+                       return image.Config{
+                               Width:      res.ImageConfig.Width,
+                               Height:     res.ImageConfig.Height,
+                               ColorModel: color.RGBAModel,
+                       }, strings.ToLower(format.String()), nil
+               }
+
+               // Fallback to the standard image.DecodeConfig.
+               rs.Seek(0, 0)
+               r = rs
        }
 
-       // Fallback to the standard image.DecodeConfig.
-       conf, name, err := image.DecodeConfig(rr)
+       conf, name, err := image.DecodeConfig(r)
+
        return conf, name, err
 }
 
index 00af171191dfab8e8cf707333366b01e7a44d784..ba5385b2ebf9ee54a6a368d0cac8f16ac1bc5cb4 100644 (file)
@@ -58,6 +58,9 @@ var (
                ".bmp":  BMP,
                ".gif":  GIF,
                ".webp": WEBP,
+               ".avif": AVIF,
+               ".heif": HEIF,
+               ".heic": HEIC,
        }
 
        // These are the image types we can process.
@@ -70,6 +73,13 @@ var (
                media.Builtin.WEBPType.SubType: WEBP,
        }
 
+       // We cannot process these formats, but we can provide metadata support for them (including width/height).
+       metaOnlyImageSubTypes = map[string]Format{
+               media.Builtin.AVIFType.SubType: AVIF,
+               media.Builtin.HEIFType.SubType: HEIF,
+               media.Builtin.HEICType.SubType: HEIC,
+       }
+
        // Increment to mark all processed images as stale. Only use when absolutely needed.
        // See the finer grained smartCropVersionNumber.
        mainImageVersionNumber = 1
@@ -125,9 +135,29 @@ func ImageFormatFromExt(ext string) (Format, bool) {
        return f, found
 }
 
-func ImageFormatFromMediaSubType(sub string) (Format, bool) {
+type ImageResourceType int
+
+const (
+       // ImageResourceTypeNone means that the resource is not an image, and thus does not support any image operations.
+       ImageResourceTypeNone ImageResourceType = iota
+       // This is an image, but with no support for any image operations.
+       ImageResourceTypeBasic
+       // ImageResourceTypeMetaOnly means that only metadata operations (e.g. getting width/height and other metadata) are supported for this format.
+       ImageResourceTypeMetaOnly
+       // ImageResourceTypeProcessable means that all image operations (resizing, cropping, etc.) are supported for this format.
+       ImageResourceTypeProcessable
+)
+
+// ImageFormatFromMediaSubType returns the image format for the given media subtype, and how much image processing operations are supported for this format.
+func ImageFormatFromMediaSubType(sub string) (Format, ImageResourceType) {
        f, found := processableImageSubTypes[sub]
-       return f, found
+       if found {
+               return f, ImageResourceTypeProcessable
+       }
+       if f, found = metaOnlyImageSubTypes[sub]; found {
+               return f, ImageResourceTypeMetaOnly
+       }
+       return f, ImageResourceTypeBasic
 }
 
 const (
index 24519c1f18f4bc4fffd1c6fe36a69adee8a54d80..d3c139bbe4ce104c8e5c341e0f518ec611c4b6d5 100644 (file)
@@ -94,7 +94,7 @@ func (i Image) WithSpec(s Spec) *Image {
 func (i *Image) InitConfig(r io.Reader) error {
        var err error
        i.configInit.Do(func() {
-               i.config, _, err = i.Proc.Codec.DecodeConfig(r)
+               i.config, _, err = i.Proc.Codec.DecodeConfig(i.Format, r)
        })
        return err
 }
@@ -114,7 +114,7 @@ func (i *Image) initConfig() error {
                }
                defer f.Close()
 
-               i.config, _, err = i.Proc.Codec.DecodeConfig(f)
+               i.config, _, err = i.Proc.Codec.DecodeConfig(i.Format, f)
        })
 
        if err != nil {
@@ -342,8 +342,18 @@ const (
        TIFF
        BMP
        WEBP
+
+       // Below: We have no encoder/decoder for these, but we can provide metadata support for them (including width/height).
+       AVIF
+       HEIF
+       HEIC
 )
 
+// Whether to use imagemeta to decode image config (width/height       ).
+func (f Format) UseImageMetaConfigDecoder() bool {
+       return f == WEBP || f == AVIF || f == HEIF || f == HEIC
+}
+
 func (f Format) ToImageMetaImageFormatFormat() imagemeta.ImageFormat {
        switch f {
        case JPEG:
@@ -354,6 +364,12 @@ func (f Format) ToImageMetaImageFormatFormat() imagemeta.ImageFormat {
                return imagemeta.TIFF
        case WEBP:
                return imagemeta.WebP
+       case AVIF:
+               return imagemeta.AVIF
+       case HEIF:
+               return imagemeta.HEIF
+       case HEIC:
+               return imagemeta.HEIF
        default:
                return -1
        }
@@ -396,6 +412,12 @@ func (f Format) MediaType() media.Type {
                return media.Builtin.BMPType
        case WEBP:
                return media.Builtin.WEBPType
+       case AVIF:
+               return media.Builtin.AVIFType
+       case HEIF:
+               return media.Builtin.HEIFType
+       case HEIC:
+               return media.Builtin.HEICType
        default:
                panic(fmt.Sprintf("%d is not a valid image format", f))
        }
@@ -415,6 +437,12 @@ func (f Format) String() string {
                return "BMP"
        case WEBP:
                return "WEBP"
+       case AVIF:
+               return "AVIF"
+       case HEIF:
+               return "HEIF"
+       case HEIC:
+               return "HEIC"
        default:
                return "Unknown"
        }
index c44a186e1ef0db970ade730e658d86a0adafc0d2..634c0ed4209bede85c7d71fc28110658ddd763f7 100644 (file)
@@ -229,3 +229,74 @@ Home.
        b.Assert(err, qt.IsNotNil)
        b.Assert(err.Error(), qt.Contains, `invalid metadata source "foo" in imaging.meta.sources config; must be one of [exif iptc xmp]`)
 }
+
+func TestAVIFMetaWidthAndHeight(t *testing.T) {
+       t.Parallel()
+
+       files := `
+-- hugo.toml --
+[imaging.meta]
+fields = ['**']
+sources = ['exif', 'iptc', 'xmp']
+-- assets/sunset.avif --
+sourcefilename: ../../testdata/sunset.avif
+-- assets/sunset.jpg --
+sourcefilename: ../../testdata/sunset.jpg
+-- assets/mytext.txt --
+This is a text file, not an image.
+-- assets/mysvg.svg --
+<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100">
+  <rect width="100" height="100" fill="blue" />
+</svg>
+-- layouts/home.html --
+{{ $txt := resources.Get "mytext.txt" }}
+{{ $svg := resources.Get "mysvg.svg" }}
+{{ $avif := resources.Get "sunset.avif" }}
+{{ $jpg := resources.Get "sunset.jpg" }}
+{{ $ic := images.Config "/assets/sunset.avif" }}
+$avif.Width/Height: {{ $avif.Width }}x{{ $avif.Height }}
+$ic.Width/Height: {{ $ic.Width }}x{{ $ic.Height }}
+
+{{ template "is-meta-etc" dict "what" "AVIF" "dot" $avif -}}
+{{ template "is-meta-etc" dict "what" "JPG" "dot" $jpg -}}
+{{ template "is-meta-etc" dict "what" "TXT" "dot" $txt -}}
+{{ template "is-meta-etc" dict "what" "SVG" "dot" $svg -}}
+{{ $meta := $avif.Meta }}
+Num Exif tags: {{ $meta.Exif | len }}|
+{{ define "is-meta-etc"}}
+IsImageResource {{ .what }}: {{ if reflect.IsImageResource .dot }}true{{ else }}false{{ end }}
+IsImageResourceWithMeta {{ .what }}: {{ if reflect.IsImageResourceWithMeta .dot }}true{{ else }}false{{ end }}
+IsImageResourceProcessable {{ .what }}: {{ if reflect.IsImageResourceProcessable .dot }}true{{ else }}false{{ end }}
+{{ end }}
+
+`
+
+       b := hugolib.Test(t, files)
+
+       b.AssertFileContent("public/index.html",
+               `
+$avif.Width/Height: 900x562
+$ic.Width/Height: 900x562
+
+
+IsImageResource AVIF: true
+IsImageResourceWithMeta AVIF: true
+IsImageResourceProcessable AVIF: false
+
+IsImageResource JPG: true
+IsImageResourceWithMeta JPG: true
+IsImageResourceProcessable JPG: true
+
+IsImageResource TXT: false
+IsImageResourceWithMeta TXT: false
+IsImageResourceProcessable TXT: false
+
+IsImageResource SVG: true
+IsImageResourceWithMeta SVG: false
+IsImageResourceProcessable SVG: false
+
+Num Exif tags: 52|
+`,
+       )
+}
index c80a0bbf9430ed6daae548bbb8050f6953e986b4..ac2e0c30da3b684d5fc67cabd1046403dc781335 100644 (file)
@@ -186,8 +186,9 @@ func (r *Spec) NewResource(rd ResourceSourceDescriptor) (resource.Resource, erro
 
        isImage := rd.MediaType.MainType == "image"
        var imgFormat images.Format
+       var imgOpsSupport images.ImageResourceType
        if isImage {
-               imgFormat, isImage = images.ImageFormatFromMediaSubType(rd.MediaType.SubType)
+               imgFormat, imgOpsSupport = images.ImageFormatFromMediaSubType(rd.MediaType.SubType)
        }
 
        gr := &genericResource{
@@ -204,7 +205,7 @@ func (r *Spec) NewResource(rd ResourceSourceDescriptor) (resource.Resource, erro
                title:            rd.Title,
        }
 
-       if isImage {
+       if imgOpsSupport >= images.ImageResourceTypeMetaOnly {
                ir := newImageResource(images.NewImage(imgFormat, r.Imaging, nil, gr), gr)
                return newResourceAdapter(gr.spec, rd.LazyPublish, ir), nil
        }
diff --git a/resources/testdata/sunset.avif b/resources/testdata/sunset.avif
new file mode 100644 (file)
index 0000000..d7812fe
Binary files /dev/null and b/resources/testdata/sunset.avif differ
index c13e12bee144cbe87c6c24d00a0ab831b1ed03fd..bcacf5471e3e5067c9b8660cfe06b9c20b9595c5 100644 (file)
@@ -404,18 +404,18 @@ func (r *resourceAdapter) getImageOps() images.ImageResourceOps {
        return img
 }
 
-// IsImage reports whether the given resource is an image that can be processed.
-func IsImage(v any) bool {
+// ResolveImageOpsSupport reports the ImageOpsSupport for the given resource. This can be used to determine if a resource supports image operations like Resize, Crop, etc.
+func ResolveImageOpsSupport(v any) images.ImageResourceType {
        r, ok := v.(resource.Resource)
        if !ok {
-               return false
+               return images.ImageResourceTypeNone
        }
        mt := r.MediaType()
        if mt.MainType != "image" {
-               return false
+               return images.ImageResourceTypeNone
        }
-       _, isImage := images.ImageFormatFromMediaSubType(mt.SubType)
-       return isImage
+       _, support := images.ImageFormatFromMediaSubType(mt.SubType)
+       return support
 }
 
 func (r *resourceAdapter) publish() {
index 7c441fee005c2bf87aa6b1ecef10570f94905f23..8185bf18646da8d4a351ed541e2ee71256136aad 100644 (file)
@@ -19,6 +19,7 @@ import (
        "fmt"
        "image"
        "path"
+       "path/filepath"
        "sync"
 
        "github.com/bep/overlayfs"
@@ -94,7 +95,10 @@ func (ns *Namespace) Config(path any) (image.Config, error) {
        }
        defer f.Close()
 
-       config, _, err = ns.deps.ResourceSpec.Imaging.Codec.DecodeConfig(f)
+       ext := filepath.Ext(filename)
+       format, _ := images.ImageFormatFromExt(ext)
+
+       config, _, err = ns.deps.ResourceSpec.Imaging.Codec.DecodeConfig(format, f)
        if err != nil {
                return config, err
        }
index d5e6ed4830346d7c6f947b1ec2bbd26a3a959fed..94f5e77003cca5835778c747c2669688d20d0dd7 100644 (file)
@@ -16,6 +16,7 @@ package reflect
 import (
        "github.com/gohugoio/hugo/common/hreflect"
        "github.com/gohugoio/hugo/resources"
+       "github.com/gohugoio/hugo/resources/images"
        "github.com/gohugoio/hugo/resources/page"
        "github.com/gohugoio/hugo/resources/resource"
 )
@@ -56,8 +57,18 @@ func (ns *Namespace) IsSite(v any) bool {
        return ok
 }
 
-// IsImageResource reports whether v is a Hugo Image Resource.
-// If this returns true, you may process it and get information about its width, height, etc.
+// IsImageResource reports whether v is a Image Resource.
 func (ns *Namespace) IsImageResource(v any) bool {
-       return resources.IsImage(v)
+       return resources.ResolveImageOpsSupport(v) > images.ImageResourceTypeNone
+}
+
+// IsImageResourceWithMeta reports whether v is a Image Resource that supports at least the image metadata operations Width and Height and Meta (for e.g. Exif).
+// This will return true for AVIF, HEIF and HEIC image resources, even if we don't yet support image operations like Resize, Crop, etc. on these formats.
+func (ns *Namespace) IsImageResourceWithMeta(v any) bool {
+       return resources.ResolveImageOpsSupport(v) >= images.ImageResourceTypeMetaOnly
+}
+
+// IsImageResourceProcessable reports whether v is a Image Resource that supports all image processing operations like Resize, Crop, etc. in addition to the metadata operations.
+func (ns *Namespace) IsImageResourceProcessable(v any) bool {
+       return resources.ResolveImageOpsSupport(v) >= images.ImageResourceTypeProcessable
 }
index cd4e23564ecece2b588d9efc8f756e5830d6a05f..159b256a94b4106aa9e16ba01d58781417dd7950 100644 (file)
@@ -47,7 +47,6 @@ AVIF.ResourceType: {{ $d.ResourceType }}
 IsSite: false: {{ reflect.IsSite . }}|true: {{ reflect.IsSite .Site }}|true: {{ reflect.IsSite site }}
 IsPage: true: {{ reflect.IsPage . }}|false: {{ reflect.IsPage .Site }}|false: {{ reflect.IsPage site }}
 IsResource: true: {{ reflect.IsResource . }}|true: {{ reflect.IsResource $a }}|true: {{ reflect.IsResource $b }}|true: {{ reflect.IsResource $c }}
-IsImageResource: false: {{ reflect.IsImageResource . }}|true: {{ reflect.IsImageResource $a }}|true: {{ reflect.IsImageResource $a10 }}|false: {{ reflect.IsImageResource $b }}|false: {{ reflect.IsImageResource $c }}|false: {{ reflect.IsImageResource $d }}
 
 
 
@@ -63,6 +62,5 @@ AVIF.ResourceType: image
 IsSite: false: false|true: true|true: true
 IsPage: true: true|false: false|false: false
 IsResource: true: true|true: true|true: true|true: true
-IsImageResource: false: false|true: true|true: true|false: false|false: false|false: false
 `)
 }