From: Bjørn Erik Pedersen Date: Fri, 16 Jan 2026 19:06:44 +0000 (+0100) Subject: Add XMP and IPTC image metadata support X-Git-Url: http://git.maquefel.me/?a=commitdiff_plain;h=8e2e60dd452ce971e83e62fcf5aa85e1b19b1d7b;p=brevno-suite%2Fhugo Add XMP and IPTC image metadata support 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 Closes #13146 --- diff --git a/go.mod b/go.mod index c50eeacf8..cc0708cb0 100644 --- 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 eefa53c71..a5840ca2b 100644 --- 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= diff --git a/resources/image.go b/resources/image.go index 694d0f752..573c22c9d 100644 --- a/resources/image.go +++ b/resources/image.go @@ -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 { diff --git a/resources/images/auto_orient.go b/resources/images/auto_orient.go index a4a61976d..ca4911e81 100644 --- a/resources/images/auto_orient.go +++ b/resources/images/auto_orient.go @@ -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 } diff --git a/resources/images/config.go b/resources/images/config.go index f72f0af23..c24651da5 100644 --- a/resources/images/config.go +++ b/resources/images/config.go @@ -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 index 8819cd6fe..000000000 --- a/resources/images/exif/exif.go +++ /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 index 3aab7d8bb..000000000 --- a/resources/images/exif/exif_test.go +++ /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) - } - }) - } -} diff --git a/resources/images/image.go b/resources/images/image.go index ba518841a..89b15a4f9 100644 --- a/resources/images/image.go +++ b/resources/images/image.go @@ -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 diff --git a/resources/images/image_resource.go b/resources/images/image_resource.go index 7cede07dd..6612a8b7a 100644 --- a/resources/images/image_resource.go +++ b/resources/images/image_resource.go @@ -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 index 000000000..a8bad0f10 --- /dev/null +++ b/resources/images/meta/exif_test.go @@ -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 index 000000000..f76b5d74e --- /dev/null +++ b/resources/images/meta/meta.go @@ -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 index 000000000..4936faf5a --- /dev/null +++ b/resources/images/meta/meta_integration_test.go @@ -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]`) +} diff --git a/resources/resource_spec.go b/resources/resource_spec.go index c91166cc7..70ec2cf0a 100644 --- a/resources/resource_spec.go +++ b/resources/resource_spec.go @@ -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 diff --git a/resources/transform.go b/resources/transform.go index 4d3d7c1b8..c13e12bee 100644 --- a/resources/transform.go +++ b/resources/transform.go @@ -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() }