From 8e2e60dd452ce971e83e62fcf5aa85e1b19b1d7b Mon Sep 17 00:00:00 2001 From: =?utf8?q?Bj=C3=B8rn=20Erik=20Pedersen?= Date: Fri, 16 Jan 2026 20:06:44 +0100 Subject: [PATCH] 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 --- go.mod | 2 +- go.sum | 4 +- resources/image.go | 179 +++++++++----- resources/images/auto_orient.go | 16 +- resources/images/config.go | 54 ++++- resources/images/image.go | 38 ++- resources/images/image_resource.go | 8 +- resources/images/{exif => meta}/exif_test.go | 2 +- .../images/{exif/exif.go => meta/meta.go} | 221 ++++++++++++++++-- .../images/meta/meta_integration_test.go | 204 ++++++++++++++++ resources/resource_spec.go | 7 +- resources/transform.go | 8 +- 12 files changed, 632 insertions(+), 111 deletions(-) rename resources/images/{exif => meta}/exif_test.go (99%) rename resources/images/{exif/exif.go => meta/meta.go} (58%) create mode 100644 resources/images/meta/meta_integration_test.go 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/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/exif/exif_test.go b/resources/images/meta/exif_test.go similarity index 99% rename from resources/images/exif/exif_test.go rename to resources/images/meta/exif_test.go index 3aab7d8bb..a8bad0f10 100644 --- a/resources/images/exif/exif_test.go +++ b/resources/images/meta/exif_test.go @@ -11,7 +11,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -package exif +package meta import ( "encoding/json" diff --git a/resources/images/exif/exif.go b/resources/images/meta/meta.go similarity index 58% rename from resources/images/exif/exif.go rename to resources/images/meta/meta.go index 8819cd6fe..f76b5d74e 100644 --- a/resources/images/exif/exif.go +++ b/resources/images/meta/meta.go @@ -11,7 +11,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -package exif +package meta import ( "fmt" @@ -24,8 +24,32 @@ import ( "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. @@ -42,21 +66,32 @@ type ExifInfo struct { } type Decoder struct { + // For ExifConfig (legacy, regexp-based) 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)) + // For MetaConfig (glob-based) + fieldsPredicate predicate.P[string] + + noDate bool + noLatLong bool + sources imagemeta.Source + warnl logg.LevelLogger } -func (d *Decoder) shouldExclude(s string) bool { - return d.excludeFieldsrRe != nil && d.excludeFieldsrRe.MatchString(s) +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) @@ -68,6 +103,7 @@ func IncludeFields(expression string) func(*Decoder) error { } } +// 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) @@ -79,6 +115,31 @@ func ExcludeFields(expression string) func(*Decoder) error { } } +// 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 @@ -100,6 +161,24 @@ func WithWarnLogger(warnl logg.LevelLogger) func(*Decoder) error { } } +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 == "" { @@ -147,7 +226,7 @@ func (d *Decoder) Decode(filename string, format imagemeta.ImageFormat, r io.Rea return nil } - shouldInclude := func(ti imagemeta.TagInfo) bool { + shouldHandleTag := func(ti imagemeta.TagInfo) bool { if ti.Source == imagemeta.EXIF { if !d.noDate { // We need the time tags to calculate the date. @@ -168,11 +247,7 @@ func (d *Decoder) Decode(filename string, format imagemeta.ImageFormat, r io.Rea } } - if d.shouldExclude(ti.Tag) { - return false - } - - return d.shouldInclude(ti.Tag) + return d.shouldIncludeField(ti.Tag) } var warnf func(string, ...any) @@ -191,7 +266,7 @@ func (d *Decoder) Decode(filename string, format imagemeta.ImageFormat, r io.Rea imagemeta.Options{ R: r.(io.ReadSeeker), ImageFormat: format, - ShouldHandleTag: shouldInclude, + ShouldHandleTag: shouldHandleTag, HandleTag: handleTag, Sources: imagemeta.EXIF, // For now. TODO(bep) Warnf: warnf, @@ -211,10 +286,7 @@ func (d *Decoder) Decode(filename string, format imagemeta.ImageFormat, r io.Rea tags := make(map[string]any) for k, v := range tagInfos.All() { - if d.shouldExclude(k) { - continue - } - if !d.shouldInclude(k) { + if !d.shouldIncludeField(k) { continue } tags[k] = v.Value @@ -225,6 +297,115 @@ func (d *Decoder) Decode(filename string, format imagemeta.ImageFormat, r io.Rea 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() { 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() } -- 2.39.5