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
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=
"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"
// 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 {
// 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) {
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.
}
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 {
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 {
"image/draw"
"github.com/disintegration/gift"
- "github.com/gohugoio/hugo/resources/images/exif"
- "github.com/spf13/cast"
)
var _ gift.Filter = (*autoOrientFilter)(nil)
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) {
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
}
"errors"
"fmt"
"image/color"
+ "maps"
+ "slices"
"strconv"
"strings"
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
}
BgColor string
Exif ExifConfig
+ Meta MetaConfig
+}
+
+var validMetaSources = map[string]bool{
+ "exif": true,
+ "iptc": true,
+ "xmp": true,
}
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
}
// .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
+}
+++ /dev/null
-// 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)
-}
+++ /dev/null
-// 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)
- }
- })
- }
-}
"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"
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
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
import (
"image"
- "github.com/gohugoio/hugo/resources/images/exif"
+ "github.com/gohugoio/hugo/resources/images/meta"
"github.com/gohugoio/hugo/resources/resource"
)
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.
--- /dev/null
+// 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)
+ }
+ })
+ }
+}
--- /dev/null
+// 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)
+}
--- /dev/null
+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]`)
+}
}
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
"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"
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()
}