Repository: gohugoio/hugo (language: go)

Issue:
Unable to decode Exif metadata from image in Hugo 0.88
<!-- Please answer these questions before submitting your issue. Thanks! -->

hugo version: hugo v0.88.1-5BC54738 linux/amd64 BuildDate=2021-09-04T09:39:19Z VendorInfo=gohugoio

Trying to read EXIF data from jpeg images I get similar warnings:
WARN 2021/09/26 13:16:45 Unable to decode Exif metadata from image: /images/2021_01_30/fulls/DSCF2441.jpg

The Images are accesible as resources and are correctly retrieved and used. An example code snippet that I'm trying to use:

    {{ range (readDir ( print "assets/" .cartella "fulls") )  }} 
        {{ $title := ""}}
        {{ $content := ""}}
        {{ $image :=  resources.Get (print $path "fulls/" .Name) }}
        {{ $thumb :=  resources.Get (print $path "thumbs/" .Name) }}
        {{ with $image.Exif }}<li>{{ .Date}}</li>{{ end }}
        {{ $button := "guarda"}}
        {{ partial "picture" (dict "title" $title "content" $content "image" $image.Permalink "thumb" $thumb.Permalink "button" $button) }}
     {{ end }}
   
The images are Jpeg images taken with a Fujifilm camera and processed in Darktable. The exif data is perfectly readable using any othere tool or library I've tried and is apparently well formed according to standards.


Discussion / hints:
Here is an example image in case it could be of any use

![DSCF7782](https://user-images.githubusercontent.com/614123/134805768-1175d5e5-6195-4ca4-8eeb-e84c107b9372.jpg)

This error caused by `exif.NewDecoder` -> `exifDecoder.Decode(image)` -> `_exif.Decode(r)` -> `/Users/jok1r/go/pkg/mod/github.com/rwcarlsen/goexif@v0.0.0-20190401172101-9e8deecbddbd/exif/exif.go:283` -> `/Users/jok1r/go/pkg/mod/github.com/rwcarlsen/goexif@v0.0.0-20190401172101-9e8deecbddbd/tiff/tiff.go:84` -> `/Users/jok1r/go/pkg/mod/github.com/rwcarlsen/goexif@v0.0.0-20190401172101-9e8deecbddbd/tiff/tiff.go:131`

As below code `t.Count` is 0.
```
func DecodeTag(r ReadAtReader, order binary.ByteOrder) (*Tag, error) {
	....
        err = binary.Read(r, order, &t.Count)
	if err != nil {
		return nil, errors.New("tiff: tag component count read failed: " + err.Error())
	}

	// There seems to be a relatively common corrupt tag which has a Count of
	// MaxUint32. This is probably not a valid value, so return early.
	if t.Count == 1<<32-1 {
		return t, errors.New("invalid Count offset in tag")
	}

	valLen := typeSize[t.Type] * t.Count
	if valLen == 0 {
		return t, errors.New("zero length tag value")
	}
        ....
}
```
Maybe should change a exif lib. I use this one [go-exif](https://github.com/dsoprea/go-exif) is ok 

> changed with https://github.com/dsoprea/go-exif/blob/master/exif-read-tool/main.go

![image](https://user-images.githubusercontent.com/49771587/134813042-87147692-d209-4301-b81e-d510c57b8ac5.png)


See #8586 and #8761. 
> See #8586 and #8761.

Hope this PR will be merged soon.
@swartz-k #8761 is blocked by https://github.com/dsoprea/go-exif/pull/63.
This issue has been automatically marked as stale because it has not had recent activity. The resources of the Hugo team are limited, and so we are asking for your help.
If this is a **bug** and you can still reproduce this error on the <code>master</code> branch, please reply with all of the information you have about it in order to keep the issue open.
If this is a **feature request**, and you feel that it is still relevant and valuable, please tell us why.
This issue will automatically be closed in the near future if no further activity occurs. Thank you for all your contributions.
This issue has been automatically locked since there has not been any recent activity after it was closed. Please open a new issue for related bugs.
See https://github.com/bep/imagemeta

Fix patch:
diff --git a/go.mod b/go.mod
index 2b7327aa336..d1d4da6d84a 100644
--- a/go.mod
+++ b/go.mod
@@ -15,6 +15,7 @@ require (
 	github.com/bep/golibsass v1.1.1
 	github.com/bep/gowebp v0.3.0
 	github.com/bep/helpers v0.4.0
+	github.com/bep/imagemeta v0.7.0
 	github.com/bep/lazycache v0.4.0
 	github.com/bep/logg v0.4.0
 	github.com/bep/mclib v1.20400.20402
@@ -60,7 +61,6 @@ require (
 	github.com/pbnjay/memory v0.0.0-20210728143218-7b4eea64cf58
 	github.com/pelletier/go-toml/v2 v2.2.2
 	github.com/rogpeppe/go-internal v1.12.0
-	github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd
 	github.com/sanity-io/litter v1.5.5
 	github.com/spf13/afero v1.11.0
 	github.com/spf13/cast v1.6.0
diff --git a/go.sum b/go.sum
index 801a32cd211..b4250b46b3f 100644
--- a/go.sum
+++ b/go.sum
@@ -118,10 +118,6 @@ github.com/bep/clocks v0.5.0 h1:hhvKVGLPQWRVsBP/UB7ErrHYIO42gINVbvqxvYTPVps=
 github.com/bep/clocks v0.5.0/go.mod h1:SUq3q+OOq41y2lRQqH5fsOoxN8GbxSiT6jvoVVLCVhU=
 github.com/bep/debounce v1.2.0 h1:wXds8Kq8qRfwAOpAxHrJDbCXgC5aHSzgQb/0gKsHQqo=
 github.com/bep/debounce v1.2.0/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0=
-github.com/bep/gitmap v1.4.0 h1:GeWbPb2QDTfcZLBQmCB693N3sJmPQfeu81fDrD5r8x8=
-github.com/bep/gitmap v1.4.0/go.mod h1:n+3W1f/rot2hynsqEGxGMErPRgT41n9CkGuzPvz9cIw=
-github.com/bep/gitmap v1.5.0 h1:ExDl7HeDaRDG8FXFRTnv20qzbyJlC6ivdOboMYFvrms=
-github.com/bep/gitmap v1.5.0/go.mod h1:n+3W1f/rot2hynsqEGxGMErPRgT41n9CkGuzPvz9cIw=
 github.com/bep/gitmap v1.6.0 h1:sDuQMm9HoTL0LtlrfxjbjgAg2wHQd4nkMup2FInYzhA=
 github.com/bep/gitmap v1.6.0/go.mod h1:n+3W1f/rot2hynsqEGxGMErPRgT41n9CkGuzPvz9cIw=
 github.com/bep/goat v0.5.0 h1:S8jLXHCVy/EHIoCY+btKkmcxcXFd34a0Q63/0D4TKeA=
@@ -136,6 +132,8 @@ github.com/bep/gowebp v0.3.0 h1:MhmMrcf88pUY7/PsEhMgEP0T6fDUnRTMpN8OclDrbrY=
 github.com/bep/gowebp v0.3.0/go.mod h1:ZhFodwdiFp8ehGJpF4LdPl6unxZm9lLFjxD3z2h2AgI=
 github.com/bep/helpers v0.4.0 h1:ab9veaAiWY4ST48Oxp5usaqivDmYdB744fz+tcZ3Ifs=
 github.com/bep/helpers v0.4.0/go.mod h1:/QpHdmcPagDw7+RjkLFCvnlUc8lQ5kg4KDrEkb2Yyco=
+github.com/bep/imagemeta v0.7.0 h1:I6Ve/UToNRdnh8qOlpuiR8dX56q6qi97hOqReaMsLMk=
+github.com/bep/imagemeta v0.7.0/go.mod h1:5piPAq5Qomh07m/dPPCLN3mDJyFusvUG7VwdRD/vX0s=
 github.com/bep/lazycache v0.4.0 h1:X8yVyWNVupPd4e1jV7efi3zb7ZV/qcjKQgIQ5aPbkYI=
 github.com/bep/lazycache v0.4.0/go.mod h1:NmRm7Dexh3pmR1EignYR8PjO2cWybFQ68+QgY3VMCSc=
 github.com/bep/logg v0.4.0 h1:luAo5mO4ZkhA5M1iDVDqDqnBBnlHjmtZF6VAyTp+nCQ=
@@ -409,7 +407,6 @@ github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99
 github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
 github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
 github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd h1:CmH9+J6ZSsIjUK3dcGsnCnO41eRBOnY12zwkn5qVwgc=
-github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd/go.mod h1:hPqNNc0+uJM6H+SuU8sEs5K5IQeKccPqeSjfgcKGgPk=
 github.com/sanity-io/litter v1.5.5 h1:iE+sBxPBzoK6uaEP5Lt3fHNgpKcHXc/A2HGETy0uJQo=
 github.com/sanity-io/litter v1.5.5/go.mod h1:9gzJgR2i4ZpjZHsKvUXIRQVk7P+yM3e+jAF7bU2UI5U=
 github.com/shogo82148/go-shuffle v0.0.0-20180218125048-27e6095f230d/go.mod h1:2htx6lmL0NGLHlO8ZCf+lQBGBHIbEujyywxJArf+2Yc=
diff --git a/resources/image.go b/resources/image.go
index 8f70a665ae7..188f4962460 100644
--- a/resources/image.go
+++ b/resources/image.go
@@ -82,8 +82,9 @@ func (i *imageResource) Exif() *exif.ExifInfo {
 
 func (i *imageResource) getExif() *exif.ExifInfo {
 	i.metaInit.Do(func() {
-		supportsExif := i.Format == images.JPEG || i.Format == images.TIFF
-		if !supportsExif {
+		mf := i.Format.ToImageMetaImageFormatFormat()
+		if mf == -1 {
+			// No Exif support for this format.
 			return
 		}
 
@@ -114,7 +115,8 @@ func (i *imageResource) getExif() *exif.ExifInfo {
 			}
 			defer f.Close()
 
-			x, err := i.getSpec().imaging.DecodeExif(f)
+			filename := i.getResourcePaths().Path()
+			x, err := i.getSpec().imaging.DecodeExif(filename, mf, f)
 			if err != nil {
 				i.getSpec().Logger.Warnf("Unable to decode Exif metadata from image: %s", i.Key())
 				return nil
@@ -471,7 +473,9 @@ func (i *imageResource) clone(img image.Image) *imageResource {
 }
 
 func (i *imageResource) getImageMetaCacheTargetPath() string {
-	const imageMetaVersionNumber = 1 // Increment to invalidate the meta cache
+	// Increment to invalidate the meta cache
+	// Last increment: v0.130.0 when change to the new imagemeta library for Exif.
+	const imageMetaVersionNumber = 2
 
 	cfgHash := i.getSpec().imaging.Cfg.SourceHash
 	df := i.getResourcePaths()
diff --git a/resources/images/exif/exif.go b/resources/images/exif/exif.go
index 0374cdc961b..a7f0e0757a7 100644
--- a/resources/images/exif/exif.go
+++ b/resources/images/exif/exif.go
@@ -14,25 +14,18 @@
 package exif
 
 import (
-	"bytes"
 	"fmt"
 	"io"
-	"math"
-	"math/big"
 	"regexp"
+	"strconv"
 	"strings"
 	"time"
-	"unicode"
-	"unicode/utf8"
 
+	"github.com/bep/imagemeta"
+	"github.com/bep/logg"
 	"github.com/bep/tmc"
-
-	_exif "github.com/rwcarlsen/goexif/exif"
-	"github.com/rwcarlsen/goexif/tiff"
 )
 
-const exifTimeLayout = "2006:01:02 15:04:05"
-
 // ExifInfo holds the decoded Exif data for an Image.
 type ExifInfo struct {
 	// GPS latitude in degrees.
@@ -53,6 +46,15 @@ type Decoder struct {
 	excludeFieldsrRe *regexp.Regexp
 	noDate           bool
 	noLatLong        bool
+	warnl            logg.LevelLogger
+}
+
+func (d *Decoder) shouldInclude(s string) bool {
+	return (d.includeFieldsRe == nil || d.includeFieldsRe.MatchString(s))
+}
+
+func (d *Decoder) shouldExclude(s string) bool {
+	return d.excludeFieldsrRe != nil && d.excludeFieldsrRe.MatchString(s)
 }
 
 func IncludeFields(expression string) func(*Decoder) error {
@@ -91,6 +93,13 @@ func WithDateDisabled(disabled bool) func(*Decoder) error {
 	}
 }
 
+func WithWarnLogger(warnl logg.LevelLogger) func(*Decoder) error {
+	return func(d *Decoder) error {
+		d.warnl = warnl
+		return nil
+	}
+}
+
 func compileRegexp(expression string) (*regexp.Regexp, error) {
 	expression = strings.TrimSpace(expression)
 	if expression == "" {
@@ -115,148 +124,222 @@ func NewDecoder(options ...func(*Decoder) error) (*Decoder, error) {
 	return d, nil
 }
 
-func (d *Decoder) Decode(r io.Reader) (ex *ExifInfo, err error) {
+var (
+	isTimeTag = func(s string) bool {
+		return strings.Contains(s, "Time")
+	}
+	isGPSTag = func(s string) bool {
+		return strings.HasPrefix(s, "GPS")
+	}
+)
+
+// Filename is only used for logging.
+func (d *Decoder) Decode(filename string, format imagemeta.ImageFormat, r io.Reader) (ex *ExifInfo, err error) {
 	defer func() {
 		if r := recover(); r != nil {
 			err = fmt.Errorf("exif failed: %v", r)
 		}
 	}()
 
-	var x *_exif.Exif
-	x, err = _exif.Decode(r)
-	if err != nil {
-		if err.Error() == "EOF" {
-			// Found no Exif
-			return nil, nil
+	var tagInfos imagemeta.Tags
+	handleTag := func(ti imagemeta.TagInfo) error {
+		tagInfos.Add(ti)
+		return nil
+	}
+
+	shouldInclude := func(ti imagemeta.TagInfo) bool {
+		if ti.Source == imagemeta.EXIF {
+			if !d.noDate {
+				// We need the time tags to calculate the date.
+				if isTimeTag(ti.Tag) {
+					return true
+				}
+			}
+			if !d.noLatLong {
+				// We need to GPS tags to calculate the lat/long.
+				if isGPSTag(ti.Tag) {
+					return true
+				}
+			}
+
+			if !strings.HasPrefix(ti.Namespace, "IFD0") {
+				// Drop thumbnail tags.
+				return false
+			}
+		}
+
+		if d.shouldExclude(ti.Tag) {
+			return false
 		}
-		return
+
+		return d.shouldInclude(ti.Tag)
 	}
 
+	var warnf func(string, ...any)
+	if d.warnl != nil {
+		// There should be very little warnings (fingers crossed!),
+		// but this will typically be unrecognized formats.
+		// To be able to possibly get rid of these warnings,
+		// we need to know what images are causing them.
+		warnf = func(format string, args ...any) {
+			format = fmt.Sprintf("%q: %s: ", filename, format)
+			d.warnl.Logf(format, args...)
+		}
+	}
+
+	err = imagemeta.Decode(
+		imagemeta.Options{
+			R:               r.(io.ReadSeeker),
+			ImageFormat:     format,
+			ShouldHandleTag: shouldInclude,
+			HandleTag:       handleTag,
+			Sources:         imagemeta.EXIF, // For now. TODO(bep)
+			Warnf:           warnf,
+		},
+	)
+
 	var tm time.Time
 	var lat, long float64
 
 	if !d.noDate {
-		tm, _ = x.DateTime()
+		tm, _ = tagInfos.GetDateTime()
 	}
 
 	if !d.noLatLong {
-		lat, long, _ = x.LatLong()
-		if math.IsNaN(lat) {
-			lat = 0
-		}
-		if math.IsNaN(long) {
-			long = 0
-		}
+		lat, long, _ = tagInfos.GetLatLong()
 	}
 
-	walker := &exifWalker{x: x, vals: make(map[string]any), includeMatcher: d.includeFieldsRe, excludeMatcher: d.excludeFieldsrRe}
-	if err = x.Walk(walker); err != nil {
-		return
+	tags := make(map[string]any)
+	for k, v := range tagInfos.All() {
+		if d.shouldExclude(k) {
+			continue
+		}
+		if !d.shouldInclude(k) {
+			continue
+		}
+		tags[k] = v.Value
 	}
 
-	ex = &ExifInfo{Lat: lat, Long: long, Date: tm, Tags: walker.vals}
+	ex = &ExifInfo{Lat: lat, Long: long, Date: tm, Tags: tags}
 
 	return
 }
 
-func decodeTag(x *_exif.Exif, f _exif.FieldName, t *tiff.Tag) (any, error) {
-	switch t.Format() {
-	case tiff.StringVal, tiff.UndefVal:
-		s := nullString(t.Val)
-		if strings.Contains(string(f), "DateTime") {
-			if d, err := tryParseDate(x, s); err == nil {
-				return d, nil
-			}
+var tcodec *tmc.Codec
+
+func init() {
+	newIntadapter := func(target any) tmc.Adapter {
+		var bitSize int
+		var isSigned bool
+
+		switch target.(type) {
+		case int:
+			bitSize = 0
+			isSigned = true
+		case int8:
+			bitSize = 8
+			isSigned = true
+		case int16:
+			bitSize = 16
+			isSigned = true
+		case int32:
+			bitSize = 32
+			isSigned = true
+		case int64:
+			bitSize = 64
+			isSigned = true
+		case uint:
+			bitSize = 0
+		case uint8:
+			bitSize = 8
+		case uint16:
+			bitSize = 16
+		case uint32:
+			bitSize = 32
+		case uint64:
+			bitSize = 64
 		}
-		return s, nil
-	case tiff.OtherVal:
-		return "unknown", nil
-	}
 
-	var rv []any
-
-	for i := 0; i < int(t.Count); i++ {
-		switch t.Format() {
-		case tiff.RatVal:
-			n, d, _ := t.Rat2(i)
-			rat := big.NewRat(n, d)
-			// if t is int or t > 1, use float64
-			if rat.IsInt() || rat.Cmp(big.NewRat(1, 1)) == 1 {
-				f, _ := rat.Float64()
-				rv = append(rv, f)
+		intFromString := func(s string) (any, error) {
+			if bitSize == 0 {
+				return strconv.Atoi(s)
+			}
+
+			var v any
+			var err error
+
+			if isSigned {
+				v, err = strconv.ParseInt(s, 10, bitSize)
 			} else {
-				rv = append(rv, rat)
+				v, err = strconv.ParseUint(s, 10, bitSize)
 			}
 
-		case tiff.FloatVal:
-			v, _ := t.Float(i)
-			rv = append(rv, v)
-		case tiff.IntVal:
-			v, _ := t.Int(i)
-			rv = append(rv, v)
-		}
-	}
+			if err != nil {
+				return 0, err
+			}
 
-	if t.Count == 1 {
-		if len(rv) == 1 {
-			return rv[0], nil
-		}
-	}
+			if isSigned {
+				i := v.(int64)
+				switch target.(type) {
+				case int:
+					return int(i), nil
+				case int8:
+					return int8(i), nil
+				case int16:
+					return int16(i), nil
+				case int32:
+					return int32(i), nil
+				case int64:
+					return i, nil
+				}
+			}
 
-	return rv, nil
-}
+			i := v.(uint64)
+			switch target.(type) {
+			case uint:
+				return uint(i), nil
+			case uint8:
+				return uint8(i), nil
+			case uint16:
+				return uint16(i), nil
+			case uint32:
+				return uint32(i), nil
+			case uint64:
+				return i, nil
 
-// Code borrowed from exif.DateTime and adjusted.
-func tryParseDate(x *_exif.Exif, s string) (time.Time, error) {
-	dateStr := strings.TrimRight(s, "\x00")
-	// TODO(bep): look for timezone offset, GPS time, etc.
-	timeZone := time.Local
-	if tz, _ := x.TimeZone(); tz != nil {
-		timeZone = tz
-	}
-	return time.ParseInLocation(exifTimeLayout, dateStr, timeZone)
-}
+			}
 
-type exifWalker struct {
-	x              *_exif.Exif
-	vals           map[string]any
-	includeMatcher *regexp.Regexp
-	excludeMatcher *regexp.Regexp
-}
+			return 0, fmt.Errorf("unsupported target type %T", target)
+		}
 
-func (e *exifWalker) Walk(f _exif.FieldName, tag *tiff.Tag) error {
-	name := string(f)
-	if e.excludeMatcher != nil && e.excludeMatcher.MatchString(name) {
-		return nil
-	}
-	if e.includeMatcher != nil && !e.includeMatcher.MatchString(name) {
-		return nil
-	}
-	val, err := decodeTag(e.x, f, tag)
-	if err != nil {
-		return err
+		intToString := func(v any) (string, error) {
+			return fmt.Sprintf("%d", v), nil
+		}
+
+		return tmc.NewAdapter(target, intFromString, intToString)
 	}
-	e.vals[name] = val
-	return nil
-}
 
-func nullString(in []byte) string {
-	var rv bytes.Buffer
-	for len(in) > 0 {
-		r, size := utf8.DecodeRune(in)
-		if unicode.IsGraphic(r) {
-			rv.WriteRune(r)
-		}
-		in = in[size:]
+	ru, _ := imagemeta.NewRat[uint32](1, 2)
+	ri, _ := imagemeta.NewRat[int32](1, 2)
+	tmcAdapters := []tmc.Adapter{
+		tmc.NewAdapter(ru, nil, nil),
+		tmc.NewAdapter(ri, nil, nil),
+		newIntadapter(int(1)),
+		newIntadapter(int8(1)),
+		newIntadapter(int16(1)),
+		newIntadapter(int32(1)),
+		newIntadapter(int64(1)),
+		newIntadapter(uint(1)),
+		newIntadapter(uint8(1)),
+		newIntadapter(uint16(1)),
+		newIntadapter(uint32(1)),
+		newIntadapter(uint64(1)),
 	}
-	return rv.String()
-}
 
-var tcodec *tmc.Codec
+	tmcAdapters = append(tmc.DefaultTypeAdapters, tmcAdapters...)
 
-func init() {
 	var err error
-	tcodec, err = tmc.New()
+	tcodec, err = tmc.New(tmc.WithTypeAdapters(tmcAdapters))
 	if err != nil {
 		panic(err)
 	}
diff --git a/resources/images/image.go b/resources/images/image.go
index 1637d0cf8b0..4d5285acdd2 100644
--- a/resources/images/image.go
+++ b/resources/images/image.go
@@ -26,6 +26,8 @@ import (
 	"sync"
 
 	"github.com/bep/gowebp/libwebp/webpoptions"
+	"github.com/bep/imagemeta"
+	"github.com/bep/logg"
 	"github.com/gohugoio/hugo/config"
 	"github.com/gohugoio/hugo/resources/images/webp"
 
@@ -174,13 +176,14 @@ func (i *Image) initConfig() error {
 	return nil
 }
 
-func NewImageProcessor(cfg *config.ConfigNamespace[ImagingConfig, ImagingConfigInternal]) (*ImageProcessor, error) {
+func NewImageProcessor(warnl logg.LevelLogger, cfg *config.ConfigNamespace[ImagingConfig, ImagingConfigInternal]) (*ImageProcessor, error) {
 	e := cfg.Config.Imaging.Exif
 	exifDecoder, err := exif.NewDecoder(
 		exif.WithDateDisabled(e.DisableDate),
 		exif.WithLatLongDisabled(e.DisableLatLong),
 		exif.ExcludeFields(e.ExcludeFields),
 		exif.IncludeFields(e.IncludeFields),
+		exif.WithWarnLogger(warnl),
 	)
 	if err != nil {
 		return nil, err
@@ -197,8 +200,9 @@ type ImageProcessor struct {
 	exifDecoder *exif.Decoder
 }
 
-func (p *ImageProcessor) DecodeExif(r io.Reader) (*exif.ExifInfo, error) {
-	return p.exifDecoder.Decode(r)
+// Filename is only used for logging.
+func (p *ImageProcessor) DecodeExif(filename string, format imagemeta.ImageFormat, r io.Reader) (*exif.ExifInfo, error) {
+	return p.exifDecoder.Decode(filename, format, r)
 }
 
 func (p *ImageProcessor) FiltersFromConfig(src image.Image, conf ImageConfig) ([]gift.Filter, error) {
@@ -353,6 +357,21 @@ const (
 	WEBP
 )
 
+func (f Format) ToImageMetaImageFormatFormat() imagemeta.ImageFormat {
+	switch f {
+	case JPEG:
+		return imagemeta.JPEG
+	case PNG:
+		return imagemeta.PNG
+	case TIFF:
+		return imagemeta.TIFF
+	case WEBP:
+		return imagemeta.WebP
+	default:
+		return -1
+	}
+}
+
 // RequiresDefaultQuality returns if the default quality needs to be applied to
 // images of this format.
 func (f Format) RequiresDefaultQuality() bool {
diff --git a/resources/resource_spec.go b/resources/resource_spec.go
index ef76daa1af2..d50edeb73ce 100644
--- a/resources/resource_spec.go
+++ b/resources/resource_spec.go
@@ -60,7 +60,9 @@ func NewSpec(
 	conf := s.Cfg.GetConfig().(*allconfig.Config)
 	imgConfig := conf.Imaging
 
-	imaging, err := images.NewImageProcessor(imgConfig)
+	imagesWarnl := logger.WarnCommand("images")
+
+	imaging, err := images.NewImageProcessor(imagesWarnl, imgConfig)
 	if err != nil {
 		return nil, err
 	}
diff --git a/resources/resource_transformers/cssjs/tailwindcss.go b/resources/resource_transformers/cssjs/tailwindcss.go
index f3195c5960a..beda7a646fe 100644
--- a/resources/resource_transformers/cssjs/tailwindcss.go
+++ b/resources/resource_transformers/cssjs/tailwindcss.go
@@ -99,11 +99,6 @@ func (t *tailwindcssTransformation) Transform(ctx *resources.ResourceTransformat
 
 	cmdArgs = append(cmdArgs, options.toArgs()...)
 
-	// TODO1
-	//   npm i tailwindcss @tailwindcss/cli
-	// npm i tailwindcss@next @tailwindcss/cli@next
-	// npx tailwindcss -h
-
 	var errBuf bytes.Buffer
 
 	stderr := io.MultiWriter(infow, &errBuf)
@@ -134,7 +129,6 @@ func (t *tailwindcssTransformation) Transform(ctx *resources.ResourceTransformat
 		t.rs.Assets.Fs, t.rs.Logger, ctx.DependencyManager,
 	)
 
-	// TODO1 option {
 	src, err = imp.resolve()
 	if err != nil {
 		return err


Test patch:
diff --git a/hugolib/image_test.go b/hugolib/image_test.go
index b3b93371177..d5800851222 100644
--- a/hugolib/image_test.go
+++ b/hugolib/image_test.go
@@ -82,12 +82,13 @@ SUNSET2: {{ $resized2.RelPermalink }}/{{ $resized2.Width }}/Lat: {{ $resized2.Ex
 	// Check the file cache
 	b.AssertImage(200, 200, "resources/_gen/images/bundle/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_200x200_resize_q75_box.jpg")
 
-	b.AssertFileContent("resources/_gen/images/bundle/sunset_3166614710256882113.json",
-		"DateTimeDigitized|time.Time", "PENTAX")
+	b.AssertFileContent("resources/_gen/images/bundle/sunset_9750822043026343402.json",
+		"FocalLengthIn35mmFormat|uint16", "PENTAX")
 
 	b.AssertImage(123, 234, "resources/_gen/images/images/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_123x234_resize_q75_box.jpg")
-	b.AssertFileContent("resources/_gen/images/images/sunset_3166614710256882113.json",
-		"DateTimeDigitized|time.Time", "PENTAX")
+
+	b.AssertFileContent("resources/_gen/images/images/sunset_9750822043026343402.json",
+		"FocalLengthIn35mmFormat|uint16", "PENTAX")
 
 	b.AssertNoDuplicateWrites()
 }
diff --git a/resources/image_extended_test.go b/resources/image_extended_test.go
index 429e51fb614..2f1f4f4a487 100644
--- a/resources/image_extended_test.go
+++ b/resources/image_extended_test.go
@@ -20,22 +20,28 @@ import (
 	"testing"
 
 	qt "github.com/frankban/quicktest"
+	"github.com/gohugoio/hugo/htesting/hqt"
 	"github.com/gohugoio/hugo/media"
 )
 
 func TestImageResizeWebP(t *testing.T) {
 	c := qt.New(t)
 
-	_, image := fetchImage(c, "sunset.webp")
+	_, image := fetchImage(c, "sunrise.webp")
 
 	c.Assert(image.MediaType(), qt.Equals, media.Builtin.WEBPType)
-	c.Assert(image.RelPermalink(), qt.Equals, "/a/sunset.webp")
+	c.Assert(image.RelPermalink(), qt.Equals, "/a/sunrise.webp")
 	c.Assert(image.ResourceType(), qt.Equals, "image")
-	c.Assert(image.Exif(), qt.IsNil)
+	exif := image.Exif()
+	c.Assert(exif, qt.Not(qt.IsNil))
+	c.Assert(exif.Tags["Copyright"], qt.Equals, "Bjørn Erik Pedersen")
+	c.Assert(exif.Lat, hqt.IsSameFloat64, 36.59744166666667)
+	c.Assert(exif.Long, hqt.IsSameFloat64, -4.50846)
+	c.Assert(exif.Date.IsZero(), qt.Equals, false)
 
 	resized, err := image.Resize("123x")
 	c.Assert(err, qt.IsNil)
 	c.Assert(image.MediaType(), qt.Equals, media.Builtin.WEBPType)
-	c.Assert(resized.RelPermalink(), qt.Equals, "/a/sunset_hu36ee0b61ba924719ad36da960c273f96_59826_123x0_resize_q68_h2_linear_2.webp")
+	c.Assert(resized.RelPermalink(), qt.Equals, "/a/sunrise_hu6ad68bcbae1b79cbc2f6b451894deaf6_95652_123x0_resize_q68_h2_linear_2.webp")
 	c.Assert(resized.Width(), qt.Equals, 123)
 }
diff --git a/resources/image_test.go b/resources/image_test.go
index 7e26c1f55e0..0a53b41177b 100644
--- a/resources/image_test.go
+++ b/resources/image_test.go
@@ -19,7 +19,6 @@ import (
 	"image"
 	"image/gif"
 	"io/fs"
-	"math/big"
 	"math/rand"
 	"os"
 	"path/filepath"
@@ -30,6 +29,7 @@ import (
 	"testing"
 	"time"
 
+	"github.com/bep/imagemeta"
 	"github.com/gohugoio/hugo/htesting"
 	"github.com/gohugoio/hugo/resources/images/webp"
 
@@ -67,8 +67,13 @@ var eq = qt.CmpEquals(
 		return m1.Type == m2.Type
 	}),
 	cmp.Comparer(
-		func(v1, v2 *big.Rat) bool {
-			return v1.RatString() == v2.RatString()
+		func(v1, v2 imagemeta.Rat[uint32]) bool {
+			return v1.String() == v2.String()
+		},
+	),
+	cmp.Comparer(
+		func(v1, v2 imagemeta.Rat[int32]) bool {
+			return v1.String() == v2.String()
 		},
 	),
 	cmp.Comparer(func(v1, v2 time.Time) bool {
@@ -392,7 +397,7 @@ func TestImageResize8BitPNG(t *testing.T) {
 	c.Assert(image.MediaType().Type, qt.Equals, "image/png")
 	c.Assert(image.RelPermalink(), qt.Equals, "/a/gohugoio.png")
 	c.Assert(image.ResourceType(), qt.Equals, "image")
-	c.Assert(image.Exif(), qt.IsNil)
+	c.Assert(image.Exif(), qt.IsNotNil)
 
 	resized, err := image.Resize("800x")
 	c.Assert(err, qt.IsNil)
@@ -443,6 +448,7 @@ func TestImageExif(t *testing.T) {
 		c.Assert(lensModel, qt.Equals, "smc PENTAX-DA* 16-50mm F2.8 ED AL [IF] SDM")
 		resized, _ := image.Resize("300x200")
 		x2 := resized.Exif()
+
 		c.Assert(x2, eq, x)
 	}
 
diff --git a/resources/images/exif/exif_test.go b/resources/images/exif/exif_test.go
index 64c5a39e303..278bc761aef 100644
--- a/resources/images/exif/exif_test.go
+++ b/resources/images/exif/exif_test.go
@@ -15,13 +15,12 @@ package exif
 
 import (
 	"encoding/json"
-	"math/big"
 	"os"
 	"path/filepath"
 	"testing"
 	"time"
 
-	"github.com/gohugoio/hugo/htesting/hqt"
+	"github.com/bep/imagemeta"
 	"github.com/google/go-cmp/cmp"
 
 	qt "github.com/frankban/quicktest"
@@ -35,11 +34,12 @@ func TestExif(t *testing.T) {
 
 	d, err := NewDecoder(IncludeFields("Lens|Date"))
 	c.Assert(err, qt.IsNil)
-	x, err := d.Decode(f)
+	x, err := d.Decode("", imagemeta.JPEG, f)
 	c.Assert(err, qt.IsNil)
 	c.Assert(x.Date.Format("2006-01-02"), qt.Equals, "2017-10-27")
 
 	// Malaga: https://goo.gl/taazZy
+
 	c.Assert(x.Lat, qt.Equals, float64(36.59744166666667))
 	c.Assert(x.Long, qt.Equals, float64(-4.50846))
 
@@ -49,9 +49,9 @@ func TestExif(t *testing.T) {
 	c.Assert(ok, qt.Equals, true)
 	c.Assert(lensModel, qt.Equals, "smc PENTAX-DA* 16-50mm F2.8 ED AL [IF] SDM")
 
-	v, found = x.Tags["DateTime"]
+	v, found = x.Tags["ModifyDate"]
 	c.Assert(found, qt.Equals, true)
-	c.Assert(v, hqt.IsSameType, time.Time{})
+	c.Assert(v, qt.Equals, "2017:11:23 09:56:54")
 
 	// Verify that it survives a round-trip to JSON and back.
 	data, err := json.Marshal(x)
@@ -72,8 +72,8 @@ func TestExifPNG(t *testing.T) {
 
 	d, err := NewDecoder()
 	c.Assert(err, qt.IsNil)
-	_, err = d.Decode(f)
-	c.Assert(err, qt.Not(qt.IsNil))
+	_, err = d.Decode("", imagemeta.PNG, f)
+	c.Assert(err, qt.IsNil)
 }
 
 func TestIssue8079(t *testing.T) {
@@ -85,28 +85,11 @@ func TestIssue8079(t *testing.T) {
 
 	d, err := NewDecoder()
 	c.Assert(err, qt.IsNil)
-	x, err := d.Decode(f)
+	x, err := d.Decode("", imagemeta.JPEG, f)
 	c.Assert(err, qt.IsNil)
 	c.Assert(x.Tags["ImageDescription"], qt.Equals, "Città del Vaticano #nanoblock #vatican #vaticancity")
 }
 
-func TestNullString(t *testing.T) {
-	c := qt.New(t)
-
-	for _, test := range []struct {
-		in     string
-		expect string
-	}{
-		{"foo", "foo"},
-		{"\x20", "\x20"},
-		{"\xc4\x81", "\xc4\x81"}, // \u0101
-		{"\u0160", "\u0160"},     // non-breaking space
-	} {
-		res := nullString([]byte(test.in))
-		c.Assert(res, qt.Equals, test.expect)
-	}
-}
-
 func BenchmarkDecodeExif(b *testing.B) {
 	c := qt.New(b)
 	f, err := os.Open(filepath.FromSlash("../../testdata/sunset.jpg"))
@@ -118,7 +101,7 @@ func BenchmarkDecodeExif(b *testing.B) {
 
 	b.ResetTimer()
 	for i := 0; i < b.N; i++ {
-		_, err = d.Decode(f)
+		_, err = d.Decode("", imagemeta.JPEG, f)
 		c.Assert(err, qt.IsNil)
 		f.Seek(0, 0)
 	}
@@ -126,8 +109,13 @@ func BenchmarkDecodeExif(b *testing.B) {
 
 var eq = qt.CmpEquals(
 	cmp.Comparer(
-		func(v1, v2 *big.Rat) bool {
-			return v1.RatString() == v2.RatString()
+		func(v1, v2 imagemeta.Rat[uint32]) bool {
+			return v1.String() == v2.String()
+		},
+	),
+	cmp.Comparer(
+		func(v1, v2 imagemeta.Rat[int32]) bool {
+			return v1.String() == v2.String()
 		},
 	),
 	cmp.Comparer(func(v1, v2 time.Time) bool {
@@ -138,14 +126,15 @@ var eq = qt.CmpEquals(
 func TestIssue10738(t *testing.T) {
 	c := qt.New(t)
 
-	testFunc := func(path, include string) any {
+	testFunc := func(c *qt.C, path, include string) any {
+		c.Helper()
 		f, err := os.Open(filepath.FromSlash(path))
 		c.Assert(err, qt.IsNil)
 		defer f.Close()
 
 		d, err := NewDecoder(IncludeFields(include))
 		c.Assert(err, qt.IsNil)
-		x, err := d.Decode(f)
+		x, err := d.Decode("", imagemeta.JPEG, f)
 		c.Assert(err, qt.IsNil)
 
 		// Verify that it survives a round-trip to JSON and back.
@@ -194,7 +183,7 @@ func TestIssue10738(t *testing.T) {
 				include: "Lens|Date|ExposureTime",
 			}, want{
 				10,
-				0,
+				1,
 			},
 		},
 		{
@@ -221,7 +210,7 @@ func TestIssue10738(t *testing.T) {
 				include: "Lens|Date|ExposureTime",
 			}, want{
 				1,
-				0,
+				1,
 			},
 		},
 		{
@@ -266,7 +255,7 @@ func TestIssue10738(t *testing.T) {
 				include: "Lens|Date|ExposureTime",
 			}, want{
 				30,
-				0,
+				1,
 			},
 		},
 		{
@@ -293,19 +282,21 @@ func TestIssue10738(t *testing.T) {
 				include: "Lens|Date|ExposureTime",
 			}, want{
 				4,
-				0,
+				1,
 			},
 		},
 	}
 
 	for _, tt := range tests {
 		c.Run(tt.name, func(c *qt.C) {
-			got := testFunc(tt.args.path, tt.args.include)
+			got := testFunc(c, tt.args.path, tt.args.include)
 			switch v := got.(type) {
 			case float64:
 				c.Assert(v, qt.Equals, float64(tt.want.vN))
-			case *big.Rat:
-				c.Assert(v, eq, big.NewRat(tt.want.vN, tt.want.vD))
+			case imagemeta.Rat[uint32]:
+				r, err := imagemeta.NewRat[uint32](uint32(tt.want.vN), uint32(tt.want.vD))
+				c.Assert(err, qt.IsNil)
+				c.Assert(v, eq, r)
 			default:
 				c.Fatalf("unexpected type: %T", got)
 			}
diff --git a/resources/testdata/sunrise.webp b/resources/testdata/sunrise.webp
new file mode 100644
index 00000000000..25ea7b046bb
Binary files /dev/null and b/resources/testdata/sunrise.webp differ