Skip to content

Commit 49bfb10

Browse files
authored
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 gohugoio#14549
1 parent b7203bb commit 49bfb10

File tree

14 files changed

+222
-55
lines changed

14 files changed

+222
-55
lines changed

common/hugio/readers.go

Lines changed: 19 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -19,16 +19,10 @@ import (
1919
"strings"
2020
)
2121

22-
// ReadSeeker wraps io.Reader and io.Seeker.
23-
type ReadSeeker interface {
24-
io.Reader
25-
io.Seeker
26-
}
27-
2822
// ReadSeekCloser is implemented by afero.File. We use this as the common type for
2923
// content in Resource objects, even for strings.
3024
type ReadSeekCloser interface {
31-
ReadSeeker
25+
io.ReadSeeker
3226
io.Closer
3327
}
3428

@@ -70,7 +64,7 @@ type ReadSeekCloserProvider interface {
7064

7165
// readSeekerNopCloser implements ReadSeekCloser by doing nothing in Close.
7266
type readSeekerNopCloser struct {
73-
ReadSeeker
67+
io.ReadSeeker
7468
}
7569

7670
// Close does nothing.
@@ -79,7 +73,7 @@ func (r readSeekerNopCloser) Close() error {
7973
}
8074

8175
// NewReadSeekerNoOpCloser creates a new ReadSeekerNoOpCloser with the given ReadSeeker.
82-
func NewReadSeekerNoOpCloser(r ReadSeeker) ReadSeekCloser {
76+
func NewReadSeekerNoOpCloser(r io.ReadSeeker) ReadSeekCloser {
8377
return readSeekerNopCloser{r}
8478
}
8579

@@ -111,6 +105,22 @@ func NewReadSeekerNoOpCloserFromBytes(content []byte) readSeekerNopCloser {
111105
return readSeekerNopCloser{bytes.NewReader(content)}
112106
}
113107

108+
// NewReadSeekerNoOpCloserFromReader creates a new ReadSeekerNoOpCloser from the given io.Reader.
109+
// If the given io.Reader is not an io.ReadSeeker, the entire content will be read into memory.
110+
func NewReadSeekerNoOpCloserFromReader(r io.Reader) (readSeekerNopCloser, error) {
111+
var rs io.ReadSeeker
112+
if s, ok := r.(io.ReadSeeker); ok {
113+
rs = s
114+
} else {
115+
b, err := io.ReadAll(r)
116+
if err != nil {
117+
return readSeekerNopCloser{rs}, err
118+
}
119+
rs = bytes.NewReader(b)
120+
}
121+
return readSeekerNopCloser{rs}, nil
122+
}
123+
114124
// NewOpenReadSeekCloser creates a new ReadSeekCloser from the given ReadSeeker.
115125
// The ReadSeeker will be seeked to the beginning before returned.
116126
func NewOpenReadSeekCloser(r ReadSeekCloser) OpenReadSeekCloser {

hugolib/integrationtest_builder.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -506,7 +506,7 @@ func (s *IntegrationTestBuilder) ImageHelper(filename string) *IntegrationTestIm
506506
fs := s.fs.WorkingDirReadOnly
507507
b, err := afero.ReadFile(fs, filename)
508508
s.Assert(err, qt.IsNil)
509-
conf, format, err := s.H.ResourceSpec.Imaging.Codec.DecodeConfig(bytes.NewReader(b))
509+
conf, format, err := s.H.ResourceSpec.Imaging.Codec.DecodeConfig(0, bytes.NewReader(b))
510510
s.Assert(err, qt.IsNil)
511511
img, err := s.H.ResourceSpec.Imaging.Codec.Decode(bytes.NewReader(b))
512512
s.Assert(err, qt.IsNil)

media/builtin.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,9 @@ type BuiltinTypes struct {
2929
TIFFType Type
3030
BMPType Type
3131
WEBPType Type
32+
AVIFType Type
33+
HEIFType Type
34+
HEICType Type
3235

3336
// Common font types
3437
TrueTypeFontType Type
@@ -85,6 +88,9 @@ var Builtin = BuiltinTypes{
8588
TIFFType: Type{Type: "image/tiff"},
8689
BMPType: Type{Type: "image/bmp"},
8790
WEBPType: Type{Type: "image/webp"},
91+
AVIFType: Type{Type: "image/avif"},
92+
HEIFType: Type{Type: "image/heif"},
93+
HEICType: Type{Type: "image/heic"},
8894

8995
// Common font types
9096
TrueTypeFontType: Type{Type: "font/ttf"},
@@ -141,6 +147,9 @@ var defaultMediaTypesConfig = map[string]any{
141147
"image/tiff": map[string]any{"suffixes": []string{"tif", "tiff"}},
142148
"image/bmp": map[string]any{"suffixes": []string{"bmp"}},
143149
"image/webp": map[string]any{"suffixes": []string{"webp"}},
150+
"image/avif": map[string]any{"suffixes": []string{"avif"}},
151+
"image/heif": map[string]any{"suffixes": []string{"heif"}},
152+
"image/heic": map[string]any{"suffixes": []string{"heic"}},
144153

145154
// Common font types
146155
"font/ttf": map[string]any{"suffixes": []string{"ttf"}},

media/config_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -151,5 +151,5 @@ func TestDefaultTypes(t *testing.T) {
151151

152152
}
153153

154-
c.Assert(len(DefaultTypes), qt.Equals, 41)
154+
c.Assert(len(DefaultTypes), qt.Equals, 44)
155155
}

resources/images/codec.go

Lines changed: 31 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -25,10 +25,12 @@ import (
2525
"image/jpeg"
2626
"image/png"
2727
"io"
28+
"strings"
2829

2930
"github.com/bep/imagemeta"
3031
"github.com/bep/logg"
3132
"github.com/gohugoio/hugo/common/himage"
33+
"github.com/gohugoio/hugo/common/hugio"
3234
"golang.org/x/image/bmp"
3335
"golang.org/x/image/tiff"
3436
)
@@ -230,41 +232,44 @@ func (d *Codec) Decode(r io.Reader) (image.Image, error) {
230232
return img, err
231233
}
232234

233-
func (d *Codec) DecodeConfig(r io.Reader) (image.Config, string, error) {
235+
func (d *Codec) DecodeConfig(f Format, r io.Reader) (image.Config, string, error) {
234236
rr := toPeekReader(r)
235237
format, err := formatFromImage(rr)
236238
if err != nil {
237239
return image.Config{}, "", err
238240
}
241+
if format == 0 {
242+
format = f
243+
}
239244
r = rr
240-
if format == WEBP {
241-
if rs, ok := r.(io.ReadSeeker); ok {
242-
rs.Seek(0, 0)
243-
// Avoid spinning up a WASM runtime if we don't have to.
244-
res, err := imagemeta.Decode(
245-
imagemeta.Options{
246-
R: rs,
247-
ImageFormat: imagemeta.WebP,
248-
Sources: imagemeta.CONFIG,
249-
},
250-
)
251-
if err == nil {
252-
return image.Config{
253-
Width: res.ImageConfig.Width,
254-
Height: res.ImageConfig.Height,
255-
ColorModel: color.RGBAModel,
256-
}, "webp", nil
257-
}
258-
rs.Seek(0, 0)
259-
r = rs
245+
if format.UseImageMetaConfigDecoder() {
246+
rs, err := hugio.NewReadSeekerNoOpCloserFromReader(r)
247+
if err != nil {
248+
return image.Config{}, "", err
260249
}
261-
// Fallback to the webp codec config decode.
262-
cfg, err := d.webp.DecodeConfig(r)
263-
return cfg, "webp", err
250+
rs.Seek(0, 0)
251+
res, err := imagemeta.Decode(
252+
imagemeta.Options{
253+
R: rs,
254+
ImageFormat: format.ToImageMetaImageFormatFormat(),
255+
Sources: imagemeta.CONFIG,
256+
},
257+
)
258+
if err == nil {
259+
return image.Config{
260+
Width: res.ImageConfig.Width,
261+
Height: res.ImageConfig.Height,
262+
ColorModel: color.RGBAModel,
263+
}, strings.ToLower(format.String()), nil
264+
}
265+
266+
// Fallback to the standard image.DecodeConfig.
267+
rs.Seek(0, 0)
268+
r = rs
264269
}
265270

266-
// Fallback to the standard image.DecodeConfig.
267-
conf, name, err := image.DecodeConfig(rr)
271+
conf, name, err := image.DecodeConfig(r)
272+
268273
return conf, name, err
269274
}
270275

resources/images/config.go

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,9 @@ var (
5858
".bmp": BMP,
5959
".gif": GIF,
6060
".webp": WEBP,
61+
".avif": AVIF,
62+
".heif": HEIF,
63+
".heic": HEIC,
6164
}
6265

6366
// These are the image types we can process.
@@ -70,6 +73,13 @@ var (
7073
media.Builtin.WEBPType.SubType: WEBP,
7174
}
7275

76+
// We cannot process these formats, but we can provide metadata support for them (including width/height).
77+
metaOnlyImageSubTypes = map[string]Format{
78+
media.Builtin.AVIFType.SubType: AVIF,
79+
media.Builtin.HEIFType.SubType: HEIF,
80+
media.Builtin.HEICType.SubType: HEIC,
81+
}
82+
7383
// Increment to mark all processed images as stale. Only use when absolutely needed.
7484
// See the finer grained smartCropVersionNumber.
7585
mainImageVersionNumber = 1
@@ -125,9 +135,29 @@ func ImageFormatFromExt(ext string) (Format, bool) {
125135
return f, found
126136
}
127137

128-
func ImageFormatFromMediaSubType(sub string) (Format, bool) {
138+
type ImageResourceType int
139+
140+
const (
141+
// ImageResourceTypeNone means that the resource is not an image, and thus does not support any image operations.
142+
ImageResourceTypeNone ImageResourceType = iota
143+
// This is an image, but with no support for any image operations.
144+
ImageResourceTypeBasic
145+
// ImageResourceTypeMetaOnly means that only metadata operations (e.g. getting width/height and other metadata) are supported for this format.
146+
ImageResourceTypeMetaOnly
147+
// ImageResourceTypeProcessable means that all image operations (resizing, cropping, etc.) are supported for this format.
148+
ImageResourceTypeProcessable
149+
)
150+
151+
// ImageFormatFromMediaSubType returns the image format for the given media subtype, and how much image processing operations are supported for this format.
152+
func ImageFormatFromMediaSubType(sub string) (Format, ImageResourceType) {
129153
f, found := processableImageSubTypes[sub]
130-
return f, found
154+
if found {
155+
return f, ImageResourceTypeProcessable
156+
}
157+
if f, found = metaOnlyImageSubTypes[sub]; found {
158+
return f, ImageResourceTypeMetaOnly
159+
}
160+
return f, ImageResourceTypeBasic
131161
}
132162

133163
const (

resources/images/image.go

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,7 @@ func (i Image) WithSpec(s Spec) *Image {
9494
func (i *Image) InitConfig(r io.Reader) error {
9595
var err error
9696
i.configInit.Do(func() {
97-
i.config, _, err = i.Proc.Codec.DecodeConfig(r)
97+
i.config, _, err = i.Proc.Codec.DecodeConfig(i.Format, r)
9898
})
9999
return err
100100
}
@@ -114,7 +114,7 @@ func (i *Image) initConfig() error {
114114
}
115115
defer f.Close()
116116

117-
i.config, _, err = i.Proc.Codec.DecodeConfig(f)
117+
i.config, _, err = i.Proc.Codec.DecodeConfig(i.Format, f)
118118
})
119119

120120
if err != nil {
@@ -342,8 +342,18 @@ const (
342342
TIFF
343343
BMP
344344
WEBP
345+
346+
// Below: We have no encoder/decoder for these, but we can provide metadata support for them (including width/height).
347+
AVIF
348+
HEIF
349+
HEIC
345350
)
346351

352+
// Whether to use imagemeta to decode image config (width/height ).
353+
func (f Format) UseImageMetaConfigDecoder() bool {
354+
return f == WEBP || f == AVIF || f == HEIF || f == HEIC
355+
}
356+
347357
func (f Format) ToImageMetaImageFormatFormat() imagemeta.ImageFormat {
348358
switch f {
349359
case JPEG:
@@ -354,6 +364,12 @@ func (f Format) ToImageMetaImageFormatFormat() imagemeta.ImageFormat {
354364
return imagemeta.TIFF
355365
case WEBP:
356366
return imagemeta.WebP
367+
case AVIF:
368+
return imagemeta.AVIF
369+
case HEIF:
370+
return imagemeta.HEIF
371+
case HEIC:
372+
return imagemeta.HEIF
357373
default:
358374
return -1
359375
}
@@ -396,6 +412,12 @@ func (f Format) MediaType() media.Type {
396412
return media.Builtin.BMPType
397413
case WEBP:
398414
return media.Builtin.WEBPType
415+
case AVIF:
416+
return media.Builtin.AVIFType
417+
case HEIF:
418+
return media.Builtin.HEIFType
419+
case HEIC:
420+
return media.Builtin.HEICType
399421
default:
400422
panic(fmt.Sprintf("%d is not a valid image format", f))
401423
}
@@ -415,6 +437,12 @@ func (f Format) String() string {
415437
return "BMP"
416438
case WEBP:
417439
return "WEBP"
440+
case AVIF:
441+
return "AVIF"
442+
case HEIF:
443+
return "HEIF"
444+
case HEIC:
445+
return "HEIC"
418446
default:
419447
return "Unknown"
420448
}

resources/images/meta/meta_integration_test.go

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -229,3 +229,74 @@ Home.
229229
b.Assert(err, qt.IsNotNil)
230230
b.Assert(err.Error(), qt.Contains, `invalid metadata source "foo" in imaging.meta.sources config; must be one of [exif iptc xmp]`)
231231
}
232+
233+
func TestAVIFMetaWidthAndHeight(t *testing.T) {
234+
t.Parallel()
235+
236+
files := `
237+
-- hugo.toml --
238+
[imaging.meta]
239+
fields = ['**']
240+
sources = ['exif', 'iptc', 'xmp']
241+
-- assets/sunset.avif --
242+
sourcefilename: ../../testdata/sunset.avif
243+
-- assets/sunset.jpg --
244+
sourcefilename: ../../testdata/sunset.jpg
245+
-- assets/mytext.txt --
246+
This is a text file, not an image.
247+
-- assets/mysvg.svg --
248+
<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100">
249+
<rect width="100" height="100" fill="blue" />
250+
</svg>
251+
-- layouts/home.html --
252+
{{ $txt := resources.Get "mytext.txt" }}
253+
{{ $svg := resources.Get "mysvg.svg" }}
254+
{{ $avif := resources.Get "sunset.avif" }}
255+
{{ $jpg := resources.Get "sunset.jpg" }}
256+
{{ $ic := images.Config "/assets/sunset.avif" }}
257+
$avif.Width/Height: {{ $avif.Width }}x{{ $avif.Height }}
258+
$ic.Width/Height: {{ $ic.Width }}x{{ $ic.Height }}
259+
260+
{{ template "is-meta-etc" dict "what" "AVIF" "dot" $avif -}}
261+
{{ template "is-meta-etc" dict "what" "JPG" "dot" $jpg -}}
262+
{{ template "is-meta-etc" dict "what" "TXT" "dot" $txt -}}
263+
{{ template "is-meta-etc" dict "what" "SVG" "dot" $svg -}}
264+
265+
{{ $meta := $avif.Meta }}
266+
Num Exif tags: {{ $meta.Exif | len }}|
267+
{{ define "is-meta-etc"}}
268+
IsImageResource {{ .what }}: {{ if reflect.IsImageResource .dot }}true{{ else }}false{{ end }}
269+
IsImageResourceWithMeta {{ .what }}: {{ if reflect.IsImageResourceWithMeta .dot }}true{{ else }}false{{ end }}
270+
IsImageResourceProcessable {{ .what }}: {{ if reflect.IsImageResourceProcessable .dot }}true{{ else }}false{{ end }}
271+
{{ end }}
272+
273+
`
274+
275+
b := hugolib.Test(t, files)
276+
277+
b.AssertFileContent("public/index.html",
278+
`
279+
$avif.Width/Height: 900x562
280+
$ic.Width/Height: 900x562
281+
282+
283+
IsImageResource AVIF: true
284+
IsImageResourceWithMeta AVIF: true
285+
IsImageResourceProcessable AVIF: false
286+
287+
IsImageResource JPG: true
288+
IsImageResourceWithMeta JPG: true
289+
IsImageResourceProcessable JPG: true
290+
291+
IsImageResource TXT: false
292+
IsImageResourceWithMeta TXT: false
293+
IsImageResourceProcessable TXT: false
294+
295+
IsImageResource SVG: true
296+
IsImageResourceWithMeta SVG: false
297+
IsImageResourceProcessable SVG: false
298+
299+
Num Exif tags: 52|
300+
`,
301+
)
302+
}

0 commit comments

Comments
 (0)