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
This commit is contained in:
Bjørn Erik Pedersen
2026-02-24 19:45:12 +01:00
committed by GitHub
parent b7203bbb3a
commit 49bfb1070b
14 changed files with 222 additions and 55 deletions
+19 -9
View 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 {
+1 -1
View 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)
+9
View 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"}},
+1 -1
View File
@@ -151,5 +151,5 @@ func TestDefaultTypes(t *testing.T) {
}
c.Assert(len(DefaultTypes), qt.Equals, 41)
c.Assert(len(DefaultTypes), qt.Equals, 44)
}
+31 -26
View 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
}
+32 -2
View 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 (
+30 -2
View 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"
}
@@ -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|
`,
)
}
+3 -2
View 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
}
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

+6 -6
View 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() {
+5 -1
View 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
}
+14 -3
View 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
}
-2
View 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
`)
}