From 49bfb1070be5aaa2a98fecc95560346ba3d71281 Mon Sep 17 00:00:00 2001 From: =?utf8?q?Bj=C3=B8rn=20Erik=20Pedersen?= Date: Tue, 24 Feb 2026 19:45:12 +0100 Subject: [PATCH] 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 --- common/hugio/readers.go | 28 ++++--- hugolib/integrationtest_builder.go | 2 +- media/builtin.go | 9 +++ media/config_test.go | 2 +- resources/images/codec.go | 57 +++++++------- resources/images/config.go | 34 ++++++++- resources/images/image.go | 32 +++++++- .../images/meta/meta_integration_test.go | 71 ++++++++++++++++++ resources/resource_spec.go | 5 +- resources/testdata/sunset.avif | Bin 0 -> 40485 bytes resources/transform.go | 12 +-- tpl/images/images.go | 6 +- tpl/reflect/reflect.go | 17 ++++- tpl/reflect/reflect_integration_test.go | 2 - 14 files changed, 222 insertions(+), 55 deletions(-) create mode 100644 resources/testdata/sunset.avif 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 0000000000000000000000000000000000000000..d7812fef385ee2807801df4687d96b03d3322b70 GIT binary patch literal 40485 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 literal 0 HcmV?d00001 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 `) } -- 2.39.5