]> git.maquefel.me Git - brevno-suite/hugo/commitdiff
Add XMP and IPTC image metadata support
authorBjørn Erik Pedersen <bjorn.erik.pedersen@gmail.com>
Fri, 16 Jan 2026 19:06:44 +0000 (20:06 +0100)
committerGitHub <noreply@github.com>
Fri, 16 Jan 2026 19:06:44 +0000 (20:06 +0100)
Rename resources/images/exif to resources/images/meta and extend
metadata support to include XMP and IPTC fields. Deprecate .Exif
in favor of .Meta. Update imagemeta dependency and add configuration
options for metadata extraction.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Closes #13146

14 files changed:
go.mod
go.sum
resources/image.go
resources/images/auto_orient.go
resources/images/config.go
resources/images/exif/exif.go [deleted file]
resources/images/exif/exif_test.go [deleted file]
resources/images/image.go
resources/images/image_resource.go
resources/images/meta/exif_test.go [new file with mode: 0644]
resources/images/meta/meta.go [new file with mode: 0644]
resources/images/meta/meta_integration_test.go [new file with mode: 0644]
resources/resource_spec.go
resources/transform.go

diff --git a/go.mod b/go.mod
index c50eeacf8221e93056e841d9255da1cf7c0a1c77..cc0708cb0b3dee8aee7c220fcde14b020f483bbf 100644 (file)
--- a/go.mod
+++ b/go.mod
@@ -14,7 +14,7 @@ require (
        github.com/bep/golibsass v1.2.0
        github.com/bep/goportabletext v0.1.0
        github.com/bep/helpers v0.6.0
-       github.com/bep/imagemeta v0.13.0
+       github.com/bep/imagemeta v0.14.0
        github.com/bep/lazycache v0.8.1
        github.com/bep/logg v0.4.0
        github.com/bep/mclib v1.20400.20402
diff --git a/go.sum b/go.sum
index eefa53c71cd2741e9ae28b44288658a70d814477..a5840ca2b92bec850237aeb3e2e74b659d8c8b91 100644 (file)
--- a/go.sum
+++ b/go.sum
@@ -160,8 +160,8 @@ github.com/bep/goportabletext v0.1.0 h1:8dqym2So1cEqVZiBa4ZnMM1R9l/DnC1h4ONg4J5k
 github.com/bep/goportabletext v0.1.0/go.mod h1:6lzSTsSue75bbcyvVc0zqd1CdApuT+xkZQ6Re5DzZFg=
 github.com/bep/helpers v0.6.0 h1:qtqMCK8XPFNM9hp5Ztu9piPjxNNkk8PIyUVjg6v8Bsw=
 github.com/bep/helpers v0.6.0/go.mod h1:IOZlgx5PM/R/2wgyCatfsgg5qQ6rNZJNDpWGXqDR044=
-github.com/bep/imagemeta v0.13.0 h1:xVwqXjxAjtfM5xv9fth8lHq+JsTUy16Ym+HI+sLhMBU=
-github.com/bep/imagemeta v0.13.0/go.mod h1:3psQjuZwn53rPCa86ai0p4KKnO+QArpuWLRdi5/30q8=
+github.com/bep/imagemeta v0.14.0 h1:xmeB/XPmhrXJmSxTiE7KT4C56xfcSrcaGjVsNe+t6Ro=
+github.com/bep/imagemeta v0.14.0/go.mod h1:3psQjuZwn53rPCa86ai0p4KKnO+QArpuWLRdi5/30q8=
 github.com/bep/lazycache v0.8.1 h1:ko6ASLjkPxyV5DMWoNNZ8B2M0weyjqXX8IZkjBoBtvg=
 github.com/bep/lazycache v0.8.1/go.mod h1:pbEiFsZoq7cLXvrTll0AHOPEurB1aGGxx4jKjOtlx9w=
 github.com/bep/logg v0.4.0 h1:luAo5mO4ZkhA5M1iDVDqDqnBBnlHjmtZF6VAyTp+nCQ=
index 694d0f75239cac04a3aaf837c2099ebceb40431f..573c22c9d197196585533cf059b574443df965eb 100644 (file)
@@ -28,11 +28,12 @@ import (
 
        "github.com/gohugoio/hugo/cache/filecache"
        "github.com/gohugoio/hugo/common/hashing"
+       "github.com/gohugoio/hugo/common/hugo"
        "github.com/gohugoio/hugo/common/paths"
 
        "github.com/disintegration/gift"
 
-       "github.com/gohugoio/hugo/resources/images/exif"
+       "github.com/gohugoio/hugo/resources/images/meta"
        "github.com/gohugoio/hugo/resources/internal"
 
        "github.com/gohugoio/hugo/resources/resource"
@@ -56,103 +57,160 @@ type imageResource struct {
        // original (first).
        root *imageResource
 
-       metaInit    sync.Once
-       metaInitErr error
-       meta        *imageMeta
+       // These are only set for the root imageResource.
+       exifInfoFn func() (*meta.ExifInfo, error)
+       metaInfoFn func() (*meta.MetaInfo, error)
 
-       dominantColorInit sync.Once
-       dominantColors    []images.Color
+       colorsFn func() ([]images.Color, error)
 
        baseResource
 }
 
-type imageMeta struct {
-       Exif *exif.ExifInfo
-}
-
-func (i *imageResource) Exif() *exif.ExifInfo {
-       return i.root.getExif()
+func newImageResource(img *images.Image, base baseResource) *imageResource {
+       ir := &imageResource{
+               Image:        img,
+               baseResource: base,
+       }
+       ir.root = ir
+       ir.exifInfoFn = ir.newExifInfoFn()
+       ir.metaInfoFn = ir.newMetaInfoFn()
+       ir.colorsFn = ir.newColorsFn()
+       return ir
 }
 
-func (i *imageResource) getExif() *exif.ExifInfo {
-       i.metaInit.Do(func() {
+func (i *imageResource) newExifInfoFn() func() (*meta.ExifInfo, error) {
+       return sync.OnceValues(func() (*meta.ExifInfo, error) {
+               hugo.Deprecate("Image.Exif", "Use Image.Meta, see https://gohugo.io/content-management/image-processing/#meta", "v0.155.0")
                mf := i.Format.ToImageMetaImageFormatFormat()
                if mf == -1 {
-                       // No Exif support for this format.
-                       return
+                       return nil, nil
                }
 
+               var result *meta.ExifInfo
                key := i.getImageMetaCacheTargetPath()
 
                read := func(info filecache.ItemInfo, r io.ReadSeeker) error {
-                       meta := &imageMeta{}
                        data, err := io.ReadAll(r)
                        if err != nil {
                                return err
                        }
+                       return json.Unmarshal(data, &result)
+               }
 
-                       if err = json.Unmarshal(data, &meta); err != nil {
+               create := func(info filecache.ItemInfo, w io.WriteCloser) (err error) {
+                       defer w.Close()
+                       f, err := i.ReadSeekCloser()
+                       if err != nil {
                                return err
                        }
+                       defer f.Close()
 
-                       i.meta = meta
+                       filename := i.getResourcePaths().Path()
+                       result, 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
+                       }
 
-                       return nil
+                       enc := json.NewEncoder(w)
+                       return enc.Encode(result)
+               }
+
+               _, err := i.getSpec().ImageCache.fcache.ReadOrCreate(key, read, create)
+               return result, err
+       })
+}
+
+func (i *imageResource) newMetaInfoFn() func() (*meta.MetaInfo, error) {
+       return sync.OnceValues(func() (*meta.MetaInfo, error) {
+               mf := i.Format.ToImageMetaImageFormatFormat()
+               if mf == -1 {
+                       return nil, nil
+               }
+
+               var result *meta.MetaInfo
+               key := i.getImageMetaInfoCacheTargetPath()
+
+               read := func(info filecache.ItemInfo, r io.ReadSeeker) error {
+                       data, err := io.ReadAll(r)
+                       if err != nil {
+                               return err
+                       }
+                       return json.Unmarshal(data, &result)
                }
 
                create := func(info filecache.ItemInfo, w io.WriteCloser) (err error) {
                        defer w.Close()
-                       f, err := i.root.ReadSeekCloser()
+                       f, err := i.ReadSeekCloser()
                        if err != nil {
-                               i.metaInitErr = err
-                               return
+                               return err
                        }
                        defer f.Close()
 
                        filename := i.getResourcePaths().Path()
-                       x, err := i.getSpec().Imaging.DecodeExif(filename, mf, f)
+                       result, err = i.getSpec().Imaging.DecodeMeta(filename, mf, f)
                        if err != nil {
-                               i.getSpec().Logger.Warnf("Unable to decode Exif metadata from image: %s", i.Key())
+                               i.getSpec().Logger.Warnf("Unable to decode metadata from image: %s", i.Key())
                                return nil
                        }
 
-                       i.meta = &imageMeta{Exif: x}
-
-                       // Also write it to cache
                        enc := json.NewEncoder(w)
-                       return enc.Encode(i.meta)
+                       return enc.Encode(result)
                }
 
-               _, i.metaInitErr = i.getSpec().ImageCache.fcache.ReadOrCreate(key, read, create)
+               _, err := i.getSpec().ImageCache.fcache.ReadOrCreate(key, read, create)
+               return result, err
        })
+}
 
-       if i.metaInitErr != nil {
-               panic(fmt.Sprintf("metadata init failed: %s", i.metaInitErr))
+func (i *imageResource) newColorsFn() func() ([]images.Color, error) {
+       return sync.OnceValues(func() ([]images.Color, error) {
+               img, err := i.DecodeImage()
+               if err != nil {
+                       return nil, err
+               }
+               colors := color_extractor.ExtractColors(img)
+               result := make([]images.Color, len(colors))
+               for j, c := range colors {
+                       result[j] = images.ColorGoToColor(c)
+               }
+               return result, nil
+       })
+}
+
+func (i *imageResource) Exif() *meta.ExifInfo {
+       x, err := i.root.exifInfoFn()
+       if err != nil {
+               panic(fmt.Sprintf("exif init failed: %s", err))
        }
+       return x
+}
 
-       if i.meta == nil {
-               return nil
+func (i *imageResource) Meta() *meta.MetaInfo {
+       m, err := i.root.metaInfoFn()
+       if err != nil {
+               panic(fmt.Sprintf("meta init failed: %s", err))
        }
+       return m
+}
 
-       return i.meta.Exif
+func (i *imageResource) getImageMetaInfoCacheTargetPath() string {
+       // Increment to invalidate the meta cache
+       const imageMetaInfoVersionNumber = 1
+
+       cfgHash := i.getSpec().Imaging.Cfg.SourceHash
+       df := i.getResourcePaths()
+       p1, _ := paths.FileAndExt(df.File)
+       h := i.hash()
+       idStr := hashing.HashStringHex(h, i.size(), imageMetaInfoVersionNumber, cfgHash)
+       df.File = fmt.Sprintf("%s_%s_meta.json", p1, idStr)
+       return df.TargetPath()
 }
 
 // Colors returns a slice of the most dominant colors in an image
 // using a simple histogram method.
 func (i *imageResource) Colors() ([]images.Color, error) {
-       var err error
-       i.dominantColorInit.Do(func() {
-               var img image.Image
-               img, err = i.DecodeImage()
-               if err != nil {
-                       return
-               }
-               colors := color_extractor.ExtractColors(img)
-               for _, c := range colors {
-                       i.dominantColors = append(i.dominantColors, images.ColorGoToColor(c))
-               }
-       })
-       return i.dominantColors, nil
+       return i.colorsFn()
 }
 
 func (i *imageResource) targetPath() string {
@@ -162,20 +220,24 @@ func (i *imageResource) targetPath() string {
 // Clone is for internal use.
 func (i *imageResource) Clone() resource.Resource {
        gr := i.baseResource.Clone().(baseResource)
-       return &imageResource{
+       ir := &imageResource{
                root:         i.root,
                Image:        i.WithSpec(gr),
                baseResource: gr,
        }
+       ir.colorsFn = ir.newColorsFn()
+       return ir
 }
 
 func (i *imageResource) cloneTo(targetPath string) resource.Resource {
        gr := i.baseResource.cloneTo(targetPath).(baseResource)
-       return &imageResource{
+       ir := &imageResource{
                root:         i.root,
                Image:        i.WithSpec(gr),
                baseResource: gr,
        }
+       ir.colorsFn = ir.newColorsFn()
+       return ir
 }
 
 func (i *imageResource) cloneWithUpdates(u *transformationUpdate) (baseResource, error) {
@@ -192,11 +254,13 @@ func (i *imageResource) cloneWithUpdates(u *transformationUpdate) (baseResource,
                img = i.Image
        }
 
-       return &imageResource{
+       ir := &imageResource{
                root:         i.root,
                Image:        img,
                baseResource: base,
-       }, nil
+       }
+       ir.colorsFn = ir.newColorsFn()
+       return ir, nil
 }
 
 // Process processes the image with the given spec.
@@ -275,8 +339,11 @@ func (i *imageResource) Filter(filters ...any) (images.ImageResource, error) {
                                }
                                filters = append(filters, pFilters...)
                        } else if orientationProvider, ok := f.(images.ImageFilterFromOrientationProvider); ok {
-                               tf := orientationProvider.AutoOrient(i.Exif())
-                               if tf != nil {
+                               var orientation int
+                               if meta := i.Meta(); meta != nil {
+                                       orientation = meta.Orientation
+                               }
+                               if tf := orientationProvider.AutoOrient(orientation); tf != nil {
                                        filters = append(filters, tf)
                                }
                        } else {
@@ -420,11 +487,13 @@ func (i *imageResource) clone(img image.Image) *imageResource {
                image = i.WithSpec(spec)
        }
 
-       return &imageResource{
+       ir := &imageResource{
                Image:        image,
                root:         i.root,
                baseResource: spec,
        }
+       ir.colorsFn = ir.newColorsFn()
+       return ir
 }
 
 func (i *imageResource) getImageMetaCacheTargetPath() string {
index a4a61976d21f02cac6518a6648e5ac45ebfb8277..ca4911e816e36b47f708e07bb6ce119ea60c3042 100644 (file)
@@ -18,8 +18,6 @@ import (
        "image/draw"
 
        "github.com/disintegration/gift"
-       "github.com/gohugoio/hugo/resources/images/exif"
-       "github.com/spf13/cast"
 )
 
 var _ gift.Filter = (*autoOrientFilter)(nil)
@@ -37,7 +35,7 @@ var transformationFilters = map[int]gift.Filter{
 type autoOrientFilter struct{}
 
 type ImageFilterFromOrientationProvider interface {
-       AutoOrient(exifInfo *exif.ExifInfo) gift.Filter
+       AutoOrient(orientation int) gift.Filter
 }
 
 func (f autoOrientFilter) Draw(dst draw.Image, src image.Image, options *gift.Options) {
@@ -48,15 +46,9 @@ func (f autoOrientFilter) Bounds(srcBounds image.Rectangle) image.Rectangle {
        panic("not supported")
 }
 
-func (f autoOrientFilter) AutoOrient(exifInfo *exif.ExifInfo) gift.Filter {
-       if exifInfo != nil {
-               if v, ok := exifInfo.Tags["Orientation"]; ok {
-                       orientation := cast.ToInt(v)
-                       if filter, ok := transformationFilters[orientation]; ok {
-                               return filter
-                       }
-               }
+func (f autoOrientFilter) AutoOrient(orientation int) gift.Filter {
+       if filter, ok := transformationFilters[orientation]; ok {
+               return filter
        }
-
        return nil
 }
index f72f0af23f860d7ec2df20293ed434d52b5cddc6..c24651da5e01d71c7a6ab00bb74ef7bfa7681064 100644 (file)
@@ -17,6 +17,8 @@ import (
        "errors"
        "fmt"
        "image/color"
+       "maps"
+       "slices"
        "strconv"
        "strings"
 
@@ -203,7 +205,7 @@ func DecodeConfig(in map[string]any) (*config.ConfigNamespace[ImagingConfig, Ima
 
        ns, err := config.DecodeNamespace[ImagingConfig](in, buildConfig)
        if err != nil {
-               return nil, fmt.Errorf("failed to decode media types: %w", err)
+               return nil, err
        }
        return ns, nil
 }
@@ -449,6 +451,13 @@ type ImagingConfig struct {
        BgColor string
 
        Exif ExifConfig
+       Meta MetaConfig
+}
+
+var validMetaSources = map[string]bool{
+       "exif": true,
+       "iptc": true,
+       "xmp":  true,
 }
 
 func (cfg *ImagingConfig) init() error {
@@ -471,6 +480,27 @@ func (cfg *ImagingConfig) init() error {
                cfg.Exif.ExcludeFields = "GPS|Exif|Exposure[M|P|B]|Contrast|Resolution|Sharp|JPEG|Metering|Sensing|Saturation|ColorSpace|Flash|WhiteBalance"
        }
 
+       if len(cfg.Meta.Fields) == 0 {
+               // Default: include all fields except technical metadata.
+               // Don't change this for no good reason. Please don't.
+               cfg.Meta.Fields = []string{
+                       "! *{GPS,Exif,Exposure[MPB],Contrast,Resolution,Sharp,JPEG,Metering,Sensing,Saturation,ColorSpace,Flash,WhiteBalance}*",
+               }
+       }
+
+       if len(cfg.Meta.Sources) == 0 {
+               // Default to EXIF and IPTC (XMP is slower to decode).
+               cfg.Meta.Sources = []string{"exif", "iptc"}
+       } else {
+               // Normalize to lowercase.
+               for i, s := range cfg.Meta.Sources {
+                       cfg.Meta.Sources[i] = strings.ToLower(s)
+                       if !validMetaSources[cfg.Meta.Sources[i]] {
+                               return fmt.Errorf("invalid metadata source %q in imaging.meta.sources config; must be one of %s", s, slices.Collect(maps.Keys(validMetaSources)))
+                       }
+               }
+       }
+
        return nil
 }
 
@@ -495,3 +525,25 @@ type ExifConfig struct {
        // .Long and .Lat. Set this to true to turn it off.
        DisableLatLong bool
 }
+
+type MetaConfig struct {
+       // Glob patterns for which metadata fields to include.
+       // Use "! " prefix to exclude patterns (e.g., "! *GPS*" excludes GPS fields).
+       // Patterns are OR'd together for inclusion, AND'd for exclusion.
+       // If empty, a default set excluding technical metadata is used.
+       // Use ["**"] to include all fields.
+       Fields []string
+
+       // Hugo extracts the "photo taken" date/time into .Date by default.
+       // Set this to true to turn it off.
+       DisableDate bool
+
+       // Hugo extracts the "photo taken where" (GPS latitude and longitude) into
+       // .Long and .Lat. Set this to true to turn it off.
+       DisableLatLong bool
+
+       // Which metadata sources to include.
+       // Valid values are "exif", "iptc", "xmp".
+       // Default is ["exif", "iptc"] (XMP is excluded for performance reasons).
+       Sources []string
+}
diff --git a/resources/images/exif/exif.go b/resources/images/exif/exif.go
deleted file mode 100644 (file)
index 8819cd6..0000000
+++ /dev/null
@@ -1,366 +0,0 @@
-// Copyright 2019 The Hugo Authors. All rights reserved.
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package exif
-
-import (
-       "fmt"
-       "io"
-       "regexp"
-       "strconv"
-       "strings"
-       "time"
-
-       "github.com/bep/imagemeta"
-       "github.com/bep/logg"
-       "github.com/bep/tmc"
-)
-
-// ExifInfo holds the decoded Exif data for an Image.
-type ExifInfo struct {
-       // GPS latitude in degrees.
-       Lat float64
-
-       // GPS longitude in degrees.
-       Long float64
-
-       // Image creation date/time.
-       Date time.Time
-
-       // A collection of the available Exif tags for this Image.
-       Tags Tags
-}
-
-type Decoder struct {
-       includeFieldsRe  *regexp.Regexp
-       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 {
-       return func(d *Decoder) error {
-               re, err := compileRegexp(expression)
-               if err != nil {
-                       return err
-               }
-               d.includeFieldsRe = re
-               return nil
-       }
-}
-
-func ExcludeFields(expression string) func(*Decoder) error {
-       return func(d *Decoder) error {
-               re, err := compileRegexp(expression)
-               if err != nil {
-                       return err
-               }
-               d.excludeFieldsrRe = re
-               return nil
-       }
-}
-
-func WithLatLongDisabled(disabled bool) func(*Decoder) error {
-       return func(d *Decoder) error {
-               d.noLatLong = disabled
-               return nil
-       }
-}
-
-func WithDateDisabled(disabled bool) func(*Decoder) error {
-       return func(d *Decoder) error {
-               d.noDate = disabled
-               return nil
-       }
-}
-
-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 == "" {
-               return nil, nil
-       }
-       if !strings.HasPrefix(expression, "(") {
-               // Make it case insensitive
-               expression = "(?i)" + expression
-       }
-
-       return regexp.Compile(expression)
-}
-
-func NewDecoder(options ...func(*Decoder) error) (*Decoder, error) {
-       d := &Decoder{}
-       for _, opt := range options {
-               if err := opt(d); err != nil {
-                       return nil, err
-               }
-       }
-
-       return d, nil
-}
-
-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 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 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, _ = tagInfos.GetDateTime()
-       }
-
-       if !d.noLatLong {
-               lat, long, _ = tagInfos.GetLatLong()
-       }
-
-       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: tags}
-
-       return
-}
-
-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
-               }
-
-               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 {
-                               v, err = strconv.ParseUint(s, 10, bitSize)
-                       }
-
-                       if err != nil {
-                               return 0, err
-                       }
-
-                       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
-                               }
-                       }
-
-                       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
-
-                       }
-
-                       return 0, fmt.Errorf("unsupported target type %T", target)
-               }
-
-               intToString := func(v any) (string, error) {
-                       return fmt.Sprintf("%d", v), nil
-               }
-
-               return tmc.NewAdapter(target, intFromString, intToString)
-       }
-
-       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)),
-       }
-
-       tmcAdapters = append(tmc.DefaultTypeAdapters, tmcAdapters...)
-
-       var err error
-       tcodec, err = tmc.New(tmc.WithTypeAdapters(tmcAdapters))
-       if err != nil {
-               panic(err)
-       }
-}
-
-// Tags is a map of EXIF tags.
-type Tags map[string]any
-
-// UnmarshalJSON is for internal use only.
-func (v *Tags) UnmarshalJSON(b []byte) error {
-       vv := make(map[string]any)
-       if err := tcodec.Unmarshal(b, &vv); err != nil {
-               return err
-       }
-
-       *v = vv
-
-       return nil
-}
-
-// MarshalJSON is for internal use only.
-func (v Tags) MarshalJSON() ([]byte, error) {
-       return tcodec.Marshal(v)
-}
diff --git a/resources/images/exif/exif_test.go b/resources/images/exif/exif_test.go
deleted file mode 100644 (file)
index 3aab7d8..0000000
+++ /dev/null
@@ -1,304 +0,0 @@
-// Copyright 2019 The Hugo Authors. All rights reserved.
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package exif
-
-import (
-       "encoding/json"
-       "os"
-       "path/filepath"
-       "testing"
-       "time"
-
-       "github.com/bep/imagemeta"
-       "github.com/google/go-cmp/cmp"
-
-       qt "github.com/frankban/quicktest"
-)
-
-func TestExif(t *testing.T) {
-       c := qt.New(t)
-       f, err := os.Open(filepath.FromSlash("../../testdata/sunset.jpg"))
-       c.Assert(err, qt.IsNil)
-       defer f.Close()
-
-       d, err := NewDecoder(IncludeFields("Lens|Date"))
-       c.Assert(err, qt.IsNil)
-       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))
-
-       v, found := x.Tags["LensModel"]
-       c.Assert(found, qt.Equals, true)
-       lensModel, ok := v.(string)
-       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["ModifyDate"]
-       c.Assert(found, qt.Equals, true)
-       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)
-       c.Assert(err, qt.IsNil)
-       x2 := &ExifInfo{}
-       err = json.Unmarshal(data, x2)
-       c.Assert(err, qt.IsNil)
-
-       c.Assert(x2, eq, x)
-}
-
-func TestExifPNG(t *testing.T) {
-       c := qt.New(t)
-
-       f, err := os.Open(filepath.FromSlash("../../testdata/gohugoio.png"))
-       c.Assert(err, qt.IsNil)
-       defer f.Close()
-
-       d, err := NewDecoder()
-       c.Assert(err, qt.IsNil)
-       _, err = d.Decode("", imagemeta.PNG, f)
-       c.Assert(err, qt.IsNil)
-}
-
-func TestIssue8079(t *testing.T) {
-       c := qt.New(t)
-
-       f, err := os.Open(filepath.FromSlash("../../testdata/iss8079.jpg"))
-       c.Assert(err, qt.IsNil)
-       defer f.Close()
-
-       d, err := NewDecoder()
-       c.Assert(err, qt.IsNil)
-       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 BenchmarkDecodeExif(b *testing.B) {
-       c := qt.New(b)
-       f, err := os.Open(filepath.FromSlash("../../testdata/sunset.jpg"))
-       c.Assert(err, qt.IsNil)
-       defer f.Close()
-
-       d, err := NewDecoder()
-       c.Assert(err, qt.IsNil)
-
-       for b.Loop() {
-               _, err = d.Decode("", imagemeta.JPEG, f)
-               c.Assert(err, qt.IsNil)
-               f.Seek(0, 0)
-       }
-}
-
-var eq = qt.CmpEquals(
-       cmp.Comparer(
-               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 {
-               return v1.Unix() == v2.Unix()
-       }),
-)
-
-func TestIssue10738(t *testing.T) {
-       c := qt.New(t)
-
-       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("", imagemeta.JPEG, f)
-               c.Assert(err, qt.IsNil)
-
-               // Verify that it survives a round-trip to JSON and back.
-               data, err := json.Marshal(x)
-               c.Assert(err, qt.IsNil)
-               x2 := &ExifInfo{}
-               err = json.Unmarshal(data, x2)
-               c.Assert(err, qt.IsNil)
-
-               c.Assert(x2, eq, x)
-
-               v, found := x.Tags["ExposureTime"]
-               c.Assert(found, qt.Equals, true)
-               return v
-       }
-
-       type args struct {
-               path    string // imagePath
-               include string // includeFields
-       }
-
-       type want struct {
-               vN int64 // numerator
-               vD int64 // denominator
-       }
-
-       type testCase struct {
-               name string
-               args args
-               want want
-       }
-
-       tests := []testCase{
-               {
-                       "canon_cr2_fraction", args{
-                               path:    "../../testdata/issue10738/canon_cr2_fraction.jpg",
-                               include: "Lens|Date|ExposureTime",
-                       }, want{
-                               1,
-                               500,
-                       },
-               },
-               {
-                       "canon_cr2_integer", args{
-                               path:    "../../testdata/issue10738/canon_cr2_integer.jpg",
-                               include: "Lens|Date|ExposureTime",
-                       }, want{
-                               10,
-                               1,
-                       },
-               },
-               {
-                       "dji_dng_fraction", args{
-                               path:    "../../testdata/issue10738/dji_dng_fraction.jpg",
-                               include: "Lens|Date|ExposureTime",
-                       }, want{
-                               1,
-                               4000,
-                       },
-               },
-               {
-                       "fuji_raf_fraction", args{
-                               path:    "../../testdata/issue10738/fuji_raf_fraction.jpg",
-                               include: "Lens|Date|ExposureTime",
-                       }, want{
-                               1,
-                               250,
-                       },
-               },
-               {
-                       "fuji_raf_integer", args{
-                               path:    "../../testdata/issue10738/fuji_raf_integer.jpg",
-                               include: "Lens|Date|ExposureTime",
-                       }, want{
-                               1,
-                               1,
-                       },
-               },
-               {
-                       "leica_dng_fraction", args{
-                               path:    "../../testdata/issue10738/leica_dng_fraction.jpg",
-                               include: "Lens|Date|ExposureTime",
-                       }, want{
-                               1,
-                               100,
-                       },
-               },
-               {
-                       "lumix_rw2_fraction", args{
-                               path:    "../../testdata/issue10738/lumix_rw2_fraction.jpg",
-                               include: "Lens|Date|ExposureTime",
-                       }, want{
-                               1,
-                               400,
-                       },
-               },
-               {
-                       "nikon_nef_d5600", args{
-                               path:    "../../testdata/issue10738/nikon_nef_d5600.jpg",
-                               include: "Lens|Date|ExposureTime",
-                       }, want{
-                               1,
-                               1000,
-                       },
-               },
-               {
-                       "nikon_nef_fraction", args{
-                               path:    "../../testdata/issue10738/nikon_nef_fraction.jpg",
-                               include: "Lens|Date|ExposureTime",
-                       }, want{
-                               1,
-                               640,
-                       },
-               },
-               {
-                       "nikon_nef_integer", args{
-                               path:    "../../testdata/issue10738/nikon_nef_integer.jpg",
-                               include: "Lens|Date|ExposureTime",
-                       }, want{
-                               30,
-                               1,
-                       },
-               },
-               {
-                       "nikon_nef_fraction_2", args{
-                               path:    "../../testdata/issue10738/nikon_nef_fraction_2.jpg",
-                               include: "Lens|Date|ExposureTime",
-                       }, want{
-                               1,
-                               6400,
-                       },
-               },
-               {
-                       "sony_arw_fraction", args{
-                               path:    "../../testdata/issue10738/sony_arw_fraction.jpg",
-                               include: "Lens|Date|ExposureTime",
-                       }, want{
-                               1,
-                               160,
-                       },
-               },
-               {
-                       "sony_arw_integer", args{
-                               path:    "../../testdata/issue10738/sony_arw_integer.jpg",
-                               include: "Lens|Date|ExposureTime",
-                       }, want{
-                               4,
-                               1,
-                       },
-               },
-       }
-
-       for _, tt := range tests {
-               c.Run(tt.name, func(c *qt.C) {
-                       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 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)
-                       }
-               })
-       }
-}
index ba518841a1d7036975f9484211a0559b5d284fe3..89b15a4f985298da6c7e0680765ca074a9311ef4 100644 (file)
@@ -28,7 +28,7 @@ import (
        "github.com/gohugoio/hugo/internal/warpc"
 
        "github.com/gohugoio/hugo/media"
-       "github.com/gohugoio/hugo/resources/images/exif"
+       "github.com/gohugoio/hugo/resources/images/meta"
 
        "github.com/disintegration/gift"
 
@@ -126,12 +126,24 @@ func (i *Image) initConfig() error {
 
 func NewImageProcessor(warnl logg.LevelLogger, wasmDispatchers *warpc.Dispatchers, 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),
+       exifDecoder, err := meta.NewDecoder(
+               meta.WithDateDisabled(e.DisableDate),
+               meta.WithLatLongDisabled(e.DisableLatLong),
+               meta.ExcludeFields(e.ExcludeFields),
+               meta.IncludeFields(e.IncludeFields),
+               meta.WithWarnLogger(warnl),
+       )
+       if err != nil {
+               return nil, err
+       }
+
+       m := cfg.Config.Imaging.Meta
+       metaDecoder, err := meta.NewDecoder(
+               meta.WithDateDisabled(m.DisableDate),
+               meta.WithLatLongDisabled(m.DisableLatLong),
+               meta.WithFields(m.Fields),
+               meta.WithSources(m.Sources...),
+               meta.WithWarnLogger(warnl),
        )
        if err != nil {
                return nil, err
@@ -149,21 +161,29 @@ func NewImageProcessor(warnl logg.LevelLogger, wasmDispatchers *warpc.Dispatcher
        return &ImageProcessor{
                Cfg:         cfg,
                exifDecoder: exifDecoder,
+               metaDecoder: metaDecoder,
                Codec:       imageCodec,
        }, nil
 }
 
 type ImageProcessor struct {
        Cfg         *config.ConfigNamespace[ImagingConfig, ImagingConfigInternal]
-       exifDecoder *exif.Decoder
+       exifDecoder *meta.Decoder
+       metaDecoder *meta.Decoder
        Codec       *Codec
 }
 
 // Filename is only used for logging.
-func (p *ImageProcessor) DecodeExif(filename string, format imagemeta.ImageFormat, r io.Reader) (*exif.ExifInfo, error) {
+func (p *ImageProcessor) DecodeExif(filename string, format imagemeta.ImageFormat, r io.Reader) (*meta.ExifInfo, error) {
        return p.exifDecoder.Decode(filename, format, r)
 }
 
+// DecodeMeta decodes metadata from configured sources.
+// Filename is only used for logging.
+func (p *ImageProcessor) DecodeMeta(filename string, format imagemeta.ImageFormat, r io.Reader) (*meta.MetaInfo, error) {
+       return p.metaDecoder.DecodeMeta(filename, format, r)
+}
+
 func (p *ImageProcessor) FiltersFromConfig(src image.Image, conf ImageConfig) ([]gift.Filter, error) {
        var filters []gift.Filter
 
index 7cede07dd1234c5793ae507446f80d24a2109886..6612a8b7a3edc2d2dada9a85a0243b636aed599d 100644 (file)
@@ -16,7 +16,7 @@ package images
 import (
        "image"
 
-       "github.com/gohugoio/hugo/resources/images/exif"
+       "github.com/gohugoio/hugo/resources/images/meta"
        "github.com/gohugoio/hugo/resources/resource"
 )
 
@@ -59,7 +59,11 @@ type ImageResourceOps interface {
        Filter(filters ...any) (ImageResource, error)
 
        // Exif returns an ExifInfo object containing Image metadata.
-       Exif() *exif.ExifInfo
+       // Deprecated: Use Meta() instead.
+       Exif() *meta.ExifInfo
+
+       // Meta returns a MetaInfo object containing Image metadata from all sources (EXIF, IPTC, XMP).
+       Meta() *meta.MetaInfo
 
        // Colors returns a slice of the most dominant colors in an image
        // using a simple histogram method.
diff --git a/resources/images/meta/exif_test.go b/resources/images/meta/exif_test.go
new file mode 100644 (file)
index 0000000..a8bad0f
--- /dev/null
@@ -0,0 +1,304 @@
+// Copyright 2019 The Hugo Authors. All rights reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package meta
+
+import (
+       "encoding/json"
+       "os"
+       "path/filepath"
+       "testing"
+       "time"
+
+       "github.com/bep/imagemeta"
+       "github.com/google/go-cmp/cmp"
+
+       qt "github.com/frankban/quicktest"
+)
+
+func TestExif(t *testing.T) {
+       c := qt.New(t)
+       f, err := os.Open(filepath.FromSlash("../../testdata/sunset.jpg"))
+       c.Assert(err, qt.IsNil)
+       defer f.Close()
+
+       d, err := NewDecoder(IncludeFields("Lens|Date"))
+       c.Assert(err, qt.IsNil)
+       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))
+
+       v, found := x.Tags["LensModel"]
+       c.Assert(found, qt.Equals, true)
+       lensModel, ok := v.(string)
+       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["ModifyDate"]
+       c.Assert(found, qt.Equals, true)
+       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)
+       c.Assert(err, qt.IsNil)
+       x2 := &ExifInfo{}
+       err = json.Unmarshal(data, x2)
+       c.Assert(err, qt.IsNil)
+
+       c.Assert(x2, eq, x)
+}
+
+func TestExifPNG(t *testing.T) {
+       c := qt.New(t)
+
+       f, err := os.Open(filepath.FromSlash("../../testdata/gohugoio.png"))
+       c.Assert(err, qt.IsNil)
+       defer f.Close()
+
+       d, err := NewDecoder()
+       c.Assert(err, qt.IsNil)
+       _, err = d.Decode("", imagemeta.PNG, f)
+       c.Assert(err, qt.IsNil)
+}
+
+func TestIssue8079(t *testing.T) {
+       c := qt.New(t)
+
+       f, err := os.Open(filepath.FromSlash("../../testdata/iss8079.jpg"))
+       c.Assert(err, qt.IsNil)
+       defer f.Close()
+
+       d, err := NewDecoder()
+       c.Assert(err, qt.IsNil)
+       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 BenchmarkDecodeExif(b *testing.B) {
+       c := qt.New(b)
+       f, err := os.Open(filepath.FromSlash("../../testdata/sunset.jpg"))
+       c.Assert(err, qt.IsNil)
+       defer f.Close()
+
+       d, err := NewDecoder()
+       c.Assert(err, qt.IsNil)
+
+       for b.Loop() {
+               _, err = d.Decode("", imagemeta.JPEG, f)
+               c.Assert(err, qt.IsNil)
+               f.Seek(0, 0)
+       }
+}
+
+var eq = qt.CmpEquals(
+       cmp.Comparer(
+               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 {
+               return v1.Unix() == v2.Unix()
+       }),
+)
+
+func TestIssue10738(t *testing.T) {
+       c := qt.New(t)
+
+       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("", imagemeta.JPEG, f)
+               c.Assert(err, qt.IsNil)
+
+               // Verify that it survives a round-trip to JSON and back.
+               data, err := json.Marshal(x)
+               c.Assert(err, qt.IsNil)
+               x2 := &ExifInfo{}
+               err = json.Unmarshal(data, x2)
+               c.Assert(err, qt.IsNil)
+
+               c.Assert(x2, eq, x)
+
+               v, found := x.Tags["ExposureTime"]
+               c.Assert(found, qt.Equals, true)
+               return v
+       }
+
+       type args struct {
+               path    string // imagePath
+               include string // includeFields
+       }
+
+       type want struct {
+               vN int64 // numerator
+               vD int64 // denominator
+       }
+
+       type testCase struct {
+               name string
+               args args
+               want want
+       }
+
+       tests := []testCase{
+               {
+                       "canon_cr2_fraction", args{
+                               path:    "../../testdata/issue10738/canon_cr2_fraction.jpg",
+                               include: "Lens|Date|ExposureTime",
+                       }, want{
+                               1,
+                               500,
+                       },
+               },
+               {
+                       "canon_cr2_integer", args{
+                               path:    "../../testdata/issue10738/canon_cr2_integer.jpg",
+                               include: "Lens|Date|ExposureTime",
+                       }, want{
+                               10,
+                               1,
+                       },
+               },
+               {
+                       "dji_dng_fraction", args{
+                               path:    "../../testdata/issue10738/dji_dng_fraction.jpg",
+                               include: "Lens|Date|ExposureTime",
+                       }, want{
+                               1,
+                               4000,
+                       },
+               },
+               {
+                       "fuji_raf_fraction", args{
+                               path:    "../../testdata/issue10738/fuji_raf_fraction.jpg",
+                               include: "Lens|Date|ExposureTime",
+                       }, want{
+                               1,
+                               250,
+                       },
+               },
+               {
+                       "fuji_raf_integer", args{
+                               path:    "../../testdata/issue10738/fuji_raf_integer.jpg",
+                               include: "Lens|Date|ExposureTime",
+                       }, want{
+                               1,
+                               1,
+                       },
+               },
+               {
+                       "leica_dng_fraction", args{
+                               path:    "../../testdata/issue10738/leica_dng_fraction.jpg",
+                               include: "Lens|Date|ExposureTime",
+                       }, want{
+                               1,
+                               100,
+                       },
+               },
+               {
+                       "lumix_rw2_fraction", args{
+                               path:    "../../testdata/issue10738/lumix_rw2_fraction.jpg",
+                               include: "Lens|Date|ExposureTime",
+                       }, want{
+                               1,
+                               400,
+                       },
+               },
+               {
+                       "nikon_nef_d5600", args{
+                               path:    "../../testdata/issue10738/nikon_nef_d5600.jpg",
+                               include: "Lens|Date|ExposureTime",
+                       }, want{
+                               1,
+                               1000,
+                       },
+               },
+               {
+                       "nikon_nef_fraction", args{
+                               path:    "../../testdata/issue10738/nikon_nef_fraction.jpg",
+                               include: "Lens|Date|ExposureTime",
+                       }, want{
+                               1,
+                               640,
+                       },
+               },
+               {
+                       "nikon_nef_integer", args{
+                               path:    "../../testdata/issue10738/nikon_nef_integer.jpg",
+                               include: "Lens|Date|ExposureTime",
+                       }, want{
+                               30,
+                               1,
+                       },
+               },
+               {
+                       "nikon_nef_fraction_2", args{
+                               path:    "../../testdata/issue10738/nikon_nef_fraction_2.jpg",
+                               include: "Lens|Date|ExposureTime",
+                       }, want{
+                               1,
+                               6400,
+                       },
+               },
+               {
+                       "sony_arw_fraction", args{
+                               path:    "../../testdata/issue10738/sony_arw_fraction.jpg",
+                               include: "Lens|Date|ExposureTime",
+                       }, want{
+                               1,
+                               160,
+                       },
+               },
+               {
+                       "sony_arw_integer", args{
+                               path:    "../../testdata/issue10738/sony_arw_integer.jpg",
+                               include: "Lens|Date|ExposureTime",
+                       }, want{
+                               4,
+                               1,
+                       },
+               },
+       }
+
+       for _, tt := range tests {
+               c.Run(tt.name, func(c *qt.C) {
+                       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 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/images/meta/meta.go b/resources/images/meta/meta.go
new file mode 100644 (file)
index 0000000..f76b5d7
--- /dev/null
@@ -0,0 +1,547 @@
+// Copyright 2019 The Hugo Authors. All rights reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package meta
+
+import (
+       "fmt"
+       "io"
+       "regexp"
+       "strconv"
+       "strings"
+       "time"
+
+       "github.com/bep/imagemeta"
+       "github.com/bep/logg"
+       "github.com/bep/tmc"
+       "github.com/gohugoio/hugo/common/predicate"
+       "github.com/gohugoio/hugo/hugofs/hglob"
+       "github.com/spf13/cast"
+)
+
+// MetaInfo holds the decoded metadata for an Image; what you get in $image.Meta.
+// Note by default, only EXIF and IPTC data is decoded, unless configured otherwise.
+// If you want a consolidated view of the different tag sections, use the merge template func, e.g. {{ $m := merge .Exif .IPTC .XMP }}.
+type MetaInfo struct {
+       // GPS latitude in degrees.
+       Lat float64
+
+       // GPS longitude in degrees.
+       Long float64
+
+       // Image creation date/time.
+       Date time.Time
+
+       // Orientation tag value.
+       Orientation int
+
+       Exif Tags
+       IPTC Tags
+       XMP  Tags
+}
+
+// ExifInfo holds the decoded Exif data for an Image.
+type ExifInfo struct {
+       // GPS latitude in degrees.
+       Lat float64
+
+       // GPS longitude in degrees.
+       Long float64
+
+       // Image creation date/time.
+       Date time.Time
+
+       // A collection of the available Exif tags for this Image.
+       Tags Tags
+}
+
+type Decoder struct {
+       // For ExifConfig (legacy, regexp-based)
+       includeFieldsRe  *regexp.Regexp
+       excludeFieldsrRe *regexp.Regexp
+
+       // For MetaConfig (glob-based)
+       fieldsPredicate predicate.P[string]
+
+       noDate    bool
+       noLatLong bool
+       sources   imagemeta.Source
+       warnl     logg.LevelLogger
+}
+
+func (d *Decoder) shouldIncludeField(s string) bool {
+       // Glob-based predicate takes precedence (used by MetaConfig)
+       if d.fieldsPredicate != nil {
+               return d.fieldsPredicate(strings.ToLower(s))
+       }
+       // Fall back to regexp-based filtering (used by ExifConfig)
+       if d.excludeFieldsrRe != nil && d.excludeFieldsrRe.MatchString(s) {
+               return false
+       }
+       return d.includeFieldsRe == nil || d.includeFieldsRe.MatchString(s)
+}
+
+// IncludeFields sets a regexp for fields to include (legacy, for ExifConfig).
+func IncludeFields(expression string) func(*Decoder) error {
+       return func(d *Decoder) error {
+               re, err := compileRegexp(expression)
+               if err != nil {
+                       return err
+               }
+               d.includeFieldsRe = re
+               return nil
+       }
+}
+
+// ExcludeFields sets a regexp for fields to exclude (legacy, for ExifConfig).
+func ExcludeFields(expression string) func(*Decoder) error {
+       return func(d *Decoder) error {
+               re, err := compileRegexp(expression)
+               if err != nil {
+                       return err
+               }
+               d.excludeFieldsrRe = re
+               return nil
+       }
+}
+
+// WithFields sets glob patterns for field filtering (for MetaConfig).
+// Patterns starting with "! " are exclusions.
+func WithFields(patterns []string) func(*Decoder) error {
+       return func(d *Decoder) error {
+               if len(patterns) == 0 {
+                       return nil
+               }
+               // Lowercase patterns for case-insensitive matching
+               lowered := make([]string, len(patterns))
+               for i, p := range patterns {
+                       if after, found := strings.CutPrefix(p, hglob.NegationPrefix); found {
+                               lowered[i] = hglob.NegationPrefix + strings.ToLower(after)
+                       } else {
+                               lowered[i] = strings.ToLower(p)
+                       }
+               }
+               p, err := predicate.NewStringPredicateFromGlobs(lowered, hglob.GetGlobDot)
+               if err != nil {
+                       return err
+               }
+               d.fieldsPredicate = p
+               return nil
+       }
+}
+
+func WithLatLongDisabled(disabled bool) func(*Decoder) error {
+       return func(d *Decoder) error {
+               d.noLatLong = disabled
+               return nil
+       }
+}
+
+func WithDateDisabled(disabled bool) func(*Decoder) error {
+       return func(d *Decoder) error {
+               d.noDate = disabled
+               return nil
+       }
+}
+
+func WithWarnLogger(warnl logg.LevelLogger) func(*Decoder) error {
+       return func(d *Decoder) error {
+               d.warnl = warnl
+               return nil
+       }
+}
+
+func WithSources(sources ...string) func(*Decoder) error {
+       return func(d *Decoder) error {
+               var s imagemeta.Source
+               for _, source := range sources {
+                       switch source {
+                       case "exif":
+                               s |= imagemeta.EXIF
+                       case "iptc":
+                               s |= imagemeta.IPTC
+                       case "xmp":
+                               s |= imagemeta.XMP
+                       }
+               }
+               d.sources = s
+               return nil
+       }
+}
+
+func compileRegexp(expression string) (*regexp.Regexp, error) {
+       expression = strings.TrimSpace(expression)
+       if expression == "" {
+               return nil, nil
+       }
+       if !strings.HasPrefix(expression, "(") {
+               // Make it case insensitive
+               expression = "(?i)" + expression
+       }
+
+       return regexp.Compile(expression)
+}
+
+func NewDecoder(options ...func(*Decoder) error) (*Decoder, error) {
+       d := &Decoder{}
+       for _, opt := range options {
+               if err := opt(d); err != nil {
+                       return nil, err
+               }
+       }
+
+       return d, nil
+}
+
+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 tagInfos imagemeta.Tags
+       handleTag := func(ti imagemeta.TagInfo) error {
+               tagInfos.Add(ti)
+               return nil
+       }
+
+       shouldHandleTag := 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
+                       }
+               }
+
+               return d.shouldIncludeField(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: shouldHandleTag,
+                       HandleTag:       handleTag,
+                       Sources:         imagemeta.EXIF, // For now. TODO(bep)
+                       Warnf:           warnf,
+               },
+       )
+
+       var tm time.Time
+       var lat, long float64
+
+       if !d.noDate {
+               tm, _ = tagInfos.GetDateTime()
+       }
+
+       if !d.noLatLong {
+               lat, long, _ = tagInfos.GetLatLong()
+       }
+
+       tags := make(map[string]any)
+       for k, v := range tagInfos.All() {
+               if !d.shouldIncludeField(k) {
+                       continue
+               }
+               tags[k] = v.Value
+       }
+
+       ex = &ExifInfo{Lat: lat, Long: long, Date: tm, Tags: tags}
+
+       return
+}
+
+// DecodeMeta decodes metadata from all sources (EXIF, IPTC, XMP).
+// Filename is only used for logging.
+func (d *Decoder) DecodeMeta(filename string, format imagemeta.ImageFormat, r io.Reader) (m *MetaInfo, err error) {
+       defer func() {
+               if r := recover(); r != nil {
+                       err = fmt.Errorf("metadata decoding failed: %v", r)
+               }
+       }()
+
+       var tagInfos imagemeta.Tags
+       handleTag := func(ti imagemeta.TagInfo) error {
+               tagInfos.Add(ti)
+               return nil
+       }
+
+       shouldHandleTag := func(ti imagemeta.TagInfo) bool {
+               // Always include time and GPS tags (needed for Date, Lat, Long extraction).
+               // These may be in EXIF, XMP, or IPTC depending on the image.
+               if !d.noDate && isTimeTag(ti.Tag) {
+                       return true
+               }
+               if !d.noLatLong && isGPSTag(ti.Tag) {
+                       return true
+               }
+
+               // For EXIF, only include tags from IFD0 (skip thumbnail data).
+               if ti.Source == imagemeta.EXIF {
+                       if !strings.HasPrefix(ti.Namespace, "IFD0") {
+                               return false
+                       }
+               }
+
+               return d.shouldIncludeField(ti.Tag)
+       }
+
+       var warnf func(string, ...any)
+       if d.warnl != nil {
+               warnf = func(format string, args ...any) {
+                       format = fmt.Sprintf("%q: %s: ", filename, format)
+                       d.warnl.Logf(format, args...)
+               }
+       }
+
+       sources := d.sources
+       if sources.IsZero() {
+               sources = imagemeta.EXIF | imagemeta.IPTC | imagemeta.XMP
+       }
+
+       _, err = imagemeta.Decode(
+               imagemeta.Options{
+                       R:               r.(io.ReadSeeker),
+                       ImageFormat:     format,
+                       ShouldHandleTag: shouldHandleTag,
+                       HandleTag:       handleTag,
+                       Sources:         sources,
+                       Warnf:           warnf,
+               },
+       )
+       if err != nil {
+               return nil, err
+       }
+
+       var tm time.Time
+       var lat, long float64
+
+       if !d.noDate {
+               tm, _ = tagInfos.GetDateTime()
+       }
+
+       if !d.noLatLong {
+               lat, long, _ = tagInfos.GetLatLong()
+       }
+
+       exifTags := make(map[string]any)
+       iptcTags := make(map[string]any)
+       xmpTags := make(map[string]any)
+
+       for k, v := range tagInfos.All() {
+               if !d.shouldIncludeField(k) {
+                       continue
+               }
+               switch v.Source {
+               case imagemeta.EXIF:
+                       exifTags[k] = v.Value
+               case imagemeta.IPTC:
+                       iptcTags[k] = v.Value
+               case imagemeta.XMP:
+                       xmpTags[k] = v.Value
+               }
+       }
+
+       var orientation int
+       if v, ok := exifTags["Orientation"]; ok {
+               orientation = cast.ToInt(v)
+       }
+
+       m = &MetaInfo{
+               Lat:         lat,
+               Long:        long,
+               Date:        tm,
+               Orientation: orientation,
+               Exif:        exifTags,
+               IPTC:        iptcTags,
+               XMP:         xmpTags,
+       }
+
+       return
+}
+
+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
+               }
+
+               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 {
+                               v, err = strconv.ParseUint(s, 10, bitSize)
+                       }
+
+                       if err != nil {
+                               return 0, err
+                       }
+
+                       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
+                               }
+                       }
+
+                       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
+
+                       }
+
+                       return 0, fmt.Errorf("unsupported target type %T", target)
+               }
+
+               intToString := func(v any) (string, error) {
+                       return fmt.Sprintf("%d", v), nil
+               }
+
+               return tmc.NewAdapter(target, intFromString, intToString)
+       }
+
+       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)),
+       }
+
+       tmcAdapters = append(tmc.DefaultTypeAdapters, tmcAdapters...)
+
+       var err error
+       tcodec, err = tmc.New(tmc.WithTypeAdapters(tmcAdapters))
+       if err != nil {
+               panic(err)
+       }
+}
+
+// Tags is a map of EXIF tags.
+type Tags map[string]any
+
+// UnmarshalJSON is for internal use only.
+func (v *Tags) UnmarshalJSON(b []byte) error {
+       vv := make(map[string]any)
+       if err := tcodec.Unmarshal(b, &vv); err != nil {
+               return err
+       }
+
+       *v = vv
+
+       return nil
+}
+
+// MarshalJSON is for internal use only.
+func (v Tags) MarshalJSON() ([]byte, error) {
+       return tcodec.Marshal(v)
+}
diff --git a/resources/images/meta/meta_integration_test.go b/resources/images/meta/meta_integration_test.go
new file mode 100644 (file)
index 0000000..4936faf
--- /dev/null
@@ -0,0 +1,204 @@
+package meta_test
+
+import (
+       "testing"
+
+       qt "github.com/frankban/quicktest"
+
+       "github.com/gohugoio/hugo/hugolib"
+)
+
+func TestMeta(t *testing.T) {
+       t.Parallel()
+
+       files := `
+-- hugo.toml --
+[imaging.meta]
+sources = ['exif', 'iptc', 'xmp']
+-- assets/sunset.jpg --
+sourcefilename: ../../testdata/sunset.jpg
+-- layouts/home.html --
+{{ $img := resources.Get "sunset.jpg" }}
+{{ $meta := $img.Meta }}
+{{ with $meta }}
+Lat: {{ .Lat }}
+Long: {{ .Long }}
+Date: {{ .Date.Format "2006-01-02" }}
+ExifMake: {{ .Exif.Make }}
+ExifModel: {{ .Exif.Model }}
+ExifISO: {{ .Exif.ISO }}
+ExifArtist: {{ .Exif.Artist }}
+IPTCCountry: {{ index .IPTC "Country-PrimaryLocationName" }}
+IPTCProvince: {{ index .IPTC "Province-State" }}
+IPTCKeywords: {{ .IPTC.Keywords }}
+XMPCity: {{ .XMP.City }}
+XMPCountry: {{ .XMP.Country }}
+XMPCreator: {{ .XMP.Creator }}
+
+{{ $exifAndIPTC := merge .Exif .IPTC }}
+MergedExifIPTC_Make: {{ $exifAndIPTC.Make }}
+MergedExifIPTC_Model: {{ $exifAndIPTC.Model }}
+MergedExifIPTC_Keywords: {{ $exifAndIPTC.Keywords }}
+MergedExifIPTC_ProvinceState: {{ index $exifAndIPTC "Province-State" }}
+Same Type: {{ eq (printf "%T" $exifAndIPTC)  (printf "%T" .Exif) }}
+{{ $all := merge .XMP .Exif .IPTC }}
+MergedAll_Make: {{ $all.Make }}
+MergedAll_City: {{ $all.City }}
+MergedAll_Keywords: {{ $all.Keywords }}
+MergedAll_Creator: {{ $all.Creator }}
+{{ end }}
+
+`
+
+       b := hugolib.Test(t, files)
+
+       b.AssertFileContent("public/index.html",
+               "Lat: 36.597441",
+               "Long: -4.50846",
+               "Date: 2017-10-27",
+               "ExifMake: RICOH IMAGING COMPANY, LTD.",
+               "ExifModel: PENTAX K-3 II",
+               "ExifISO: 100",
+               "ExifArtist: bjorn.erik.pedersen@gmail.com",
+               "IPTCCountry: Spain",
+               "IPTCProvince: Andalucía",
+               "IPTCKeywords: [Malaga Torremolinos]",
+               "XMPCity: Benalmádena",
+               "XMPCountry: Spain",
+               "XMPCreator: bjorn.erik.pedersen@gmail.com",
+               // merge .Exif .IPTC: contains keys from both, IPTC values take precedence
+               "MergedExifIPTC_Make: RICOH IMAGING COMPANY, LTD.",
+               "MergedExifIPTC_Model: PENTAX K-3 II",
+               "MergedExifIPTC_Keywords: [Malaga Torremolinos]",
+               "MergedExifIPTC_ProvinceState: Andalucía",
+               "Same Type: true",
+               // merge .XMP .Exif .IPTC: contains keys from all, rightmost (IPTC) wins
+               "MergedAll_Make: RICOH IMAGING COMPANY, LTD.",
+               "MergedAll_City: Benalmádena",
+               "MergedAll_Keywords: [Malaga Torremolinos]",
+               "MergedAll_Creator: bjorn.erik.pedersen@gmail.com",
+       )
+}
+
+func TestMetaConfig(t *testing.T) {
+       t.Parallel()
+
+       files := `
+-- hugo.toml --
+[imaging]
+[imaging.meta]
+disableDate = true
+disableLatLong = true
+fields = ['! *{Model,ColorSpace,Metering}*']
+sources = ['exif', 'iptc']
+-- assets/sunset.jpg --
+sourcefilename: ../../testdata/sunset.jpg
+-- layouts/home.html --
+{{ $img := resources.Get "sunset.jpg" }}
+{{ $meta := $img.Meta }}
+{{ with $meta }}
+Lat: {{ .Lat }}
+Long: {{ .Long }}
+Date: {{ .Date.Format "2006-01-02" }}
+ExifMake: {{ .Exif.Make }}
+ExifModel: {{ with .Exif.Model }}{{ . }}{{ else }}EXCLUDED{{ end }}
+IPTCCountry: {{ index .IPTC "Country-PrimaryLocationName" }}
+XMPCity: {{ with .XMP.City }}{{ . }}{{ else }}NOT_IN_SOURCE{{ end }}
+{{ end }}
+`
+
+       b := hugolib.Test(t, files)
+
+       b.AssertFileContent("public/index.html",
+               // disableLatLong = true
+               "Lat: 0",
+               "Long: 0",
+               // disableDate = true
+               "Date: 0001-01-01",
+               // Exif is included
+               "ExifMake: RICOH IMAGING COMPANY, LTD.",
+               // Model is excluded by fields pattern '! *Model*'
+               "ExifModel: EXCLUDED",
+               // IPTC is included
+               "IPTCCountry: Spain",
+               // XMP is not in sources, so should be empty
+               "XMPCity: NOT_IN_SOURCE",
+       )
+}
+
+// TestMetaXMPOnly verifies behavior when only XMP is configured as a source.
+// Note: Date and Lat/Long are extracted from whichever configured source contains them.
+// The test image has GPS/Date in EXIF only, so with XMP-only sources they will be empty.
+// If the image had GPS/Date in XMP, they would be extracted (imagemeta v0.14.0+).
+func TestMetaXMPOnly(t *testing.T) {
+       t.Parallel()
+
+       files := `
+-- hugo.toml --
+[imaging.meta]
+sources = ['xmp']
+-- assets/sunset.jpg --
+sourcefilename: ../../testdata/sunset.jpg
+-- layouts/home.html --
+{{ $img := resources.Get "sunset.jpg" }}
+{{ $meta := $img.Meta }}
+{{ with $meta }}
+Lat: {{ .Lat }}
+Long: {{ .Long }}
+Date: {{ .Date.Format "2006-01-02" }}
+XMPCity: {{ .XMP.City }}
+XMPCountry: {{ .XMP.Country }}
+ExifMake: {{ with .Exif.Make }}{{ . }}{{ else }}NOT_IN_SOURCE{{ end }}
+{{ end }}
+`
+
+       b := hugolib.Test(t, files)
+
+       b.AssertFileContent("public/index.html",
+               // The test image has Date/GPS only in EXIF, not in XMP.
+               // With XMP-only sources, these will be empty/zero.
+               "Lat: 0",
+               "Long: 0",
+               "Date: 0001-01-01",
+               // XMP values are present
+               "XMPCity: Benalmádena",
+               "XMPCountry: Spain",
+               // EXIF is not in sources
+               "ExifMake: NOT_IN_SOURCE",
+       )
+}
+
+func TestExifIsDeprecated(t *testing.T) {
+       // This cannot be parallel.
+       files := `
+-- hugo.toml --
+-- assets/sunset.jpg --
+sourcefilename: ../../testdata/sunset.jpg
+-- layouts/home.html --
+Home.
+{{ $img := resources.Get "sunset.jpg" }}
+{{ $exif := $img.Exif }}
+`
+       b := hugolib.Test(t, files, hugolib.TestOptInfo())
+
+       b.AssertLogContains("deprecated: Image.Exif")
+}
+
+func TestMetaInvalidSource(t *testing.T) {
+       t.Parallel()
+
+       files := `
+-- hugo.toml --
+[imaging.meta]
+sources = ['foo']
+-- assets/sunset.jpg --
+sourcefilename: ../../testdata/sunset.jpg
+-- layouts/home.html --
+Home.
+{{ $img := resources.Get "sunset.jpg" }}
+{{ $exif := $img.Meta }}
+`
+       b, err := hugolib.TestE(t, files, hugolib.TestOptInfo())
+       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]`)
+}
index c91166cc714a7d199ba64ee7d5de160ee60ce868..70ec2cf0a7a5279943fdd80acbd1547222491c8e 100644 (file)
@@ -204,13 +204,8 @@ func (r *Spec) NewResource(rd ResourceSourceDescriptor) (resource.Resource, erro
        }
 
        if isImage {
-               ir := &imageResource{
-                       Image:        images.NewImage(imgFormat, r.Imaging, nil, gr),
-                       baseResource: gr,
-               }
-               ir.root = ir
+               ir := newImageResource(images.NewImage(imgFormat, r.Imaging, nil, gr), gr)
                return newResourceAdapter(gr.spec, rd.LazyPublish, ir), nil
-
        }
 
        return newResourceAdapter(gr.spec, rd.LazyPublish, gr), nil
index 4d3d7c1b8d6c9114d5bb5c79825564aa45206f30..c13e12bee144cbe87c6c24d00a0ab831b1ed03fd 100644 (file)
@@ -30,7 +30,7 @@ import (
        "github.com/gohugoio/hugo/identity"
 
        "github.com/gohugoio/hugo/resources/images"
-       "github.com/gohugoio/hugo/resources/images/exif"
+       "github.com/gohugoio/hugo/resources/images/meta"
        "github.com/spf13/afero"
 
        bp "github.com/gohugoio/hugo/bufferpool"
@@ -265,10 +265,14 @@ func (r *resourceAdapter) Height() int {
        return r.getImageOps().Height()
 }
 
-func (r *resourceAdapter) Exif() *exif.ExifInfo {
+func (r *resourceAdapter) Exif() *meta.ExifInfo {
        return r.getImageOps().Exif()
 }
 
+func (r *resourceAdapter) Meta() *meta.MetaInfo {
+       return r.getImageOps().Meta()
+}
+
 func (r *resourceAdapter) Colors() ([]images.Color, error) {
        return r.getImageOps().Colors()
 }