From: Bjørn Erik Pedersen Date: Tue, 24 Feb 2026 18:45:12 +0000 (+0100) Subject: Add AVIF, HEIF and HEIC partial support (only metadata for now) X-Git-Url: http://git.maquefel.me/?a=commitdiff_plain;h=49bfb1070be5aaa2a98fecc95560346ba3d71281;p=brevno-suite%2Fhugo Add AVIF, HEIF and HEIC partial support (only metadata for now) * 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 --- diff --git a/common/hugio/readers.go b/common/hugio/readers.go index aba54437a..7e50e6a5e 100644 --- a/common/hugio/readers.go +++ b/common/hugio/readers.go @@ -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 { diff --git a/hugolib/integrationtest_builder.go b/hugolib/integrationtest_builder.go index 4628d5f6f..ed558cbaf 100644 --- a/hugolib/integrationtest_builder.go +++ b/hugolib/integrationtest_builder.go @@ -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) diff --git a/media/builtin.go b/media/builtin.go index 41d1ed655..80c4df6fd 100644 --- a/media/builtin.go +++ b/media/builtin.go @@ -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"}}, diff --git a/media/config_test.go b/media/config_test.go index 5abbcac2f..6e626ba64 100644 --- a/media/config_test.go +++ b/media/config_test.go @@ -151,5 +151,5 @@ func TestDefaultTypes(t *testing.T) { } - c.Assert(len(DefaultTypes), qt.Equals, 41) + c.Assert(len(DefaultTypes), qt.Equals, 44) } diff --git a/resources/images/codec.go b/resources/images/codec.go index 3b1e0f383..abc10ca99 100644 --- a/resources/images/codec.go +++ b/resources/images/codec.go @@ -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 } diff --git a/resources/images/config.go b/resources/images/config.go index 00af17119..ba5385b2e 100644 --- a/resources/images/config.go +++ b/resources/images/config.go @@ -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 ( diff --git a/resources/images/image.go b/resources/images/image.go index 24519c1f1..d3c139bbe 100644 --- a/resources/images/image.go +++ b/resources/images/image.go @@ -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" } diff --git a/resources/images/meta/meta_integration_test.go b/resources/images/meta/meta_integration_test.go index c44a186e1..634c0ed42 100644 --- a/resources/images/meta/meta_integration_test.go +++ b/resources/images/meta/meta_integration_test.go @@ -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 -- + + + +-- 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| +`, + ) +} diff --git a/resources/resource_spec.go b/resources/resource_spec.go index c80a0bbf9..ac2e0c30d 100644 --- a/resources/resource_spec.go +++ b/resources/resource_spec.go @@ -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 index 000000000..d7812fef3 Binary files /dev/null and b/resources/testdata/sunset.avif differ diff --git a/resources/transform.go b/resources/transform.go index c13e12bee..bcacf5471 100644 --- a/resources/transform.go +++ b/resources/transform.go @@ -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() { diff --git a/tpl/images/images.go b/tpl/images/images.go index 7c441fee0..8185bf186 100644 --- a/tpl/images/images.go +++ b/tpl/images/images.go @@ -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 } diff --git a/tpl/reflect/reflect.go b/tpl/reflect/reflect.go index d5e6ed483..94f5e7700 100644 --- a/tpl/reflect/reflect.go +++ b/tpl/reflect/reflect.go @@ -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 } diff --git a/tpl/reflect/reflect_integration_test.go b/tpl/reflect/reflect_integration_test.go index cd4e23564..159b256a9 100644 --- a/tpl/reflect/reflect_integration_test.go +++ b/tpl/reflect/reflect_integration_test.go @@ -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 `) }