github.com/neohugo/neohugo@v0.123.8/resources/images/config.go (about)

     1  // Copyright 2019 The Hugo Authors. All rights reserved.
     2  //
     3  // Licensed under the Apache License, Version 2.0 (the "License");
     4  // you may not use this file except in compliance with the License.
     5  // You may obtain a copy of the License at
     6  // http://www.apache.org/licenses/LICENSE-2.0
     7  //
     8  // Unless required by applicable law or agreed to in writing, software
     9  // distributed under the License is distributed on an "AS IS" BASIS,
    10  // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    11  // See the License for the specific language governing permissions and
    12  // limitations under the License.
    13  
    14  package images
    15  
    16  import (
    17  	"errors"
    18  	"fmt"
    19  	"image/color"
    20  	"strconv"
    21  	"strings"
    22  
    23  	"github.com/mitchellh/mapstructure"
    24  	"github.com/neohugo/neohugo/common/maps"
    25  	"github.com/neohugo/neohugo/config"
    26  	"github.com/neohugo/neohugo/media"
    27  
    28  	"github.com/bep/gowebp/libwebp/webpoptions"
    29  
    30  	"github.com/disintegration/gift"
    31  )
    32  
    33  const (
    34  	ActionResize = "resize"
    35  	ActionCrop   = "crop"
    36  	ActionFit    = "fit"
    37  	ActionFill   = "fill"
    38  )
    39  
    40  var (
    41  	imageFormats = map[string]Format{
    42  		".jpg":  JPEG,
    43  		".jpeg": JPEG,
    44  		".jpe":  JPEG,
    45  		".jif":  JPEG,
    46  		".jfif": JPEG,
    47  		".png":  PNG,
    48  		".tif":  TIFF,
    49  		".tiff": TIFF,
    50  		".bmp":  BMP,
    51  		".gif":  GIF,
    52  		".webp": WEBP,
    53  	}
    54  
    55  	imageFormatsBySubType = map[string]Format{
    56  		media.Builtin.JPEGType.SubType: JPEG,
    57  		media.Builtin.PNGType.SubType:  PNG,
    58  		media.Builtin.TIFFType.SubType: TIFF,
    59  		media.Builtin.BMPType.SubType:  BMP,
    60  		media.Builtin.GIFType.SubType:  GIF,
    61  		media.Builtin.WEBPType.SubType: WEBP,
    62  	}
    63  
    64  	// Add or increment if changes to an image format's processing requires
    65  	// re-generation.
    66  	imageFormatsVersions = map[Format]int{
    67  		PNG:  3, // Fix transparency issue with 32 bit images.
    68  		WEBP: 2, // Fix transparency issue with 32 bit images.
    69  		GIF:  1, // Fix resize issue with animated GIFs when target != GIF.
    70  	}
    71  
    72  	// Increment to mark all processed images as stale. Only use when absolutely needed.
    73  	// See the finer grained smartCropVersionNumber and imageFormatsVersions.
    74  	mainImageVersionNumber = 0
    75  )
    76  
    77  var anchorPositions = map[string]gift.Anchor{
    78  	strings.ToLower("Center"):      gift.CenterAnchor,
    79  	strings.ToLower("TopLeft"):     gift.TopLeftAnchor,
    80  	strings.ToLower("Top"):         gift.TopAnchor,
    81  	strings.ToLower("TopRight"):    gift.TopRightAnchor,
    82  	strings.ToLower("Left"):        gift.LeftAnchor,
    83  	strings.ToLower("Right"):       gift.RightAnchor,
    84  	strings.ToLower("BottomLeft"):  gift.BottomLeftAnchor,
    85  	strings.ToLower("Bottom"):      gift.BottomAnchor,
    86  	strings.ToLower("BottomRight"): gift.BottomRightAnchor,
    87  }
    88  
    89  // These encoding hints are currently only relevant for Webp.
    90  var hints = map[string]webpoptions.EncodingPreset{
    91  	"picture": webpoptions.EncodingPresetPicture,
    92  	"photo":   webpoptions.EncodingPresetPhoto,
    93  	"drawing": webpoptions.EncodingPresetDrawing,
    94  	"icon":    webpoptions.EncodingPresetIcon,
    95  	"text":    webpoptions.EncodingPresetText,
    96  }
    97  
    98  var imageFilters = map[string]gift.Resampling{
    99  	strings.ToLower("NearestNeighbor"):   gift.NearestNeighborResampling,
   100  	strings.ToLower("Box"):               gift.BoxResampling,
   101  	strings.ToLower("Linear"):            gift.LinearResampling,
   102  	strings.ToLower("Hermite"):           hermiteResampling,
   103  	strings.ToLower("MitchellNetravali"): mitchellNetravaliResampling,
   104  	strings.ToLower("CatmullRom"):        catmullRomResampling,
   105  	strings.ToLower("BSpline"):           bSplineResampling,
   106  	strings.ToLower("Gaussian"):          gaussianResampling,
   107  	strings.ToLower("Lanczos"):           gift.LanczosResampling,
   108  	strings.ToLower("Hann"):              hannResampling,
   109  	strings.ToLower("Hamming"):           hammingResampling,
   110  	strings.ToLower("Blackman"):          blackmanResampling,
   111  	strings.ToLower("Bartlett"):          bartlettResampling,
   112  	strings.ToLower("Welch"):             welchResampling,
   113  	strings.ToLower("Cosine"):            cosineResampling,
   114  }
   115  
   116  func ImageFormatFromExt(ext string) (Format, bool) {
   117  	f, found := imageFormats[ext]
   118  	return f, found
   119  }
   120  
   121  func ImageFormatFromMediaSubType(sub string) (Format, bool) {
   122  	f, found := imageFormatsBySubType[sub]
   123  	return f, found
   124  }
   125  
   126  const (
   127  	defaultJPEGQuality    = 75
   128  	defaultResampleFilter = "box"
   129  	defaultBgColor        = "#ffffff"
   130  	defaultHint           = "photo"
   131  )
   132  
   133  var (
   134  	defaultImaging = map[string]any{
   135  		"resampleFilter": defaultResampleFilter,
   136  		"bgColor":        defaultBgColor,
   137  		"hint":           defaultHint,
   138  		"quality":        defaultJPEGQuality,
   139  	}
   140  
   141  	defaultImageConfig *config.ConfigNamespace[ImagingConfig, ImagingConfigInternal]
   142  )
   143  
   144  func init() {
   145  	var err error
   146  	defaultImageConfig, err = DecodeConfig(defaultImaging)
   147  	if err != nil {
   148  		panic(err)
   149  	}
   150  }
   151  
   152  func DecodeConfig(in map[string]any) (*config.ConfigNamespace[ImagingConfig, ImagingConfigInternal], error) {
   153  	if in == nil {
   154  		in = make(map[string]any)
   155  	}
   156  
   157  	buildConfig := func(in any) (ImagingConfigInternal, any, error) {
   158  		m, err := maps.ToStringMapE(in)
   159  		if err != nil {
   160  			return ImagingConfigInternal{}, nil, err
   161  		}
   162  		// Merge in the defaults.
   163  		maps.MergeShallow(m, defaultImaging)
   164  
   165  		var i ImagingConfigInternal
   166  		if err := mapstructure.Decode(m, &i.Imaging); err != nil {
   167  			return i, nil, err
   168  		}
   169  
   170  		if err := i.Imaging.init(); err != nil {
   171  			return i, nil, err
   172  		}
   173  
   174  		i.BgColor, err = hexStringToColor(i.Imaging.BgColor)
   175  		if err != nil {
   176  			return i, nil, err
   177  		}
   178  
   179  		if i.Imaging.Anchor != "" && i.Imaging.Anchor != smartCropIdentifier {
   180  			anchor, found := anchorPositions[i.Imaging.Anchor]
   181  			if !found {
   182  				return i, nil, fmt.Errorf("invalid anchor value %q in imaging config", i.Anchor)
   183  			}
   184  			i.Anchor = anchor
   185  		}
   186  
   187  		filter, found := imageFilters[i.Imaging.ResampleFilter]
   188  		if !found {
   189  			return i, nil, fmt.Errorf("%q is not a valid resample filter", filter)
   190  		}
   191  
   192  		i.ResampleFilter = filter
   193  
   194  		return i, nil, nil
   195  	}
   196  
   197  	ns, err := config.DecodeNamespace[ImagingConfig](in, buildConfig)
   198  	if err != nil {
   199  		return nil, fmt.Errorf("failed to decode media types: %w", err)
   200  	}
   201  	return ns, nil
   202  }
   203  
   204  func DecodeImageConfig(action string, options []string, defaults *config.ConfigNamespace[ImagingConfig, ImagingConfigInternal], sourceFormat Format) (ImageConfig, error) {
   205  	var (
   206  		c   ImageConfig = GetDefaultImageConfig(action, defaults)
   207  		err error
   208  	)
   209  
   210  	action = strings.ToLower(action)
   211  
   212  	c.Action = action
   213  
   214  	if options == nil {
   215  		return c, errors.New("image options cannot be empty")
   216  	}
   217  
   218  	for _, part := range options {
   219  		part = strings.ToLower(part)
   220  
   221  		if part == smartCropIdentifier {
   222  			c.AnchorStr = smartCropIdentifier
   223  		} else if pos, ok := anchorPositions[part]; ok {
   224  			c.Anchor = pos
   225  			c.AnchorStr = part
   226  		} else if filter, ok := imageFilters[part]; ok {
   227  			c.Filter = filter
   228  			c.FilterStr = part
   229  		} else if hint, ok := hints[part]; ok {
   230  			c.Hint = hint
   231  		} else if part[0] == '#' {
   232  			c.BgColorStr = part[1:]
   233  			c.BgColor, err = hexStringToColor(c.BgColorStr)
   234  			if err != nil {
   235  				return c, err
   236  			}
   237  		} else if part[0] == 'q' {
   238  			c.Quality, err = strconv.Atoi(part[1:])
   239  			if err != nil {
   240  				return c, err
   241  			}
   242  			if c.Quality < 1 || c.Quality > 100 {
   243  				return c, errors.New("quality ranges from 1 to 100 inclusive")
   244  			}
   245  			c.qualitySetForImage = true
   246  		} else if part[0] == 'r' {
   247  			c.Rotate, err = strconv.Atoi(part[1:])
   248  			if err != nil {
   249  				return c, err
   250  			}
   251  		} else if strings.Contains(part, "x") {
   252  			widthHeight := strings.Split(part, "x")
   253  			if len(widthHeight) <= 2 {
   254  				first := widthHeight[0]
   255  				if first != "" {
   256  					c.Width, err = strconv.Atoi(first)
   257  					if err != nil {
   258  						return c, err
   259  					}
   260  				}
   261  
   262  				if len(widthHeight) == 2 {
   263  					second := widthHeight[1]
   264  					if second != "" {
   265  						c.Height, err = strconv.Atoi(second)
   266  						if err != nil {
   267  							return c, err
   268  						}
   269  					}
   270  				}
   271  			} else {
   272  				return c, errors.New("invalid image dimensions")
   273  			}
   274  		} else if f, ok := ImageFormatFromExt("." + part); ok {
   275  			c.TargetFormat = f
   276  		}
   277  	}
   278  
   279  	switch c.Action {
   280  	case ActionCrop, ActionFill, ActionFit:
   281  		if c.Width == 0 || c.Height == 0 {
   282  			return c, errors.New("must provide Width and Height")
   283  		}
   284  	case ActionResize:
   285  		if c.Width == 0 && c.Height == 0 {
   286  			return c, errors.New("must provide Width or Height")
   287  		}
   288  	default:
   289  		if c.Width != 0 || c.Height != 0 {
   290  			return c, errors.New("width or height are not supported for this action")
   291  		}
   292  	}
   293  
   294  	if action != "" && c.FilterStr == "" {
   295  		c.FilterStr = defaults.Config.Imaging.ResampleFilter
   296  		c.Filter = defaults.Config.ResampleFilter
   297  	}
   298  
   299  	if c.Hint == 0 {
   300  		c.Hint = webpoptions.EncodingPresetPhoto
   301  	}
   302  
   303  	if action != "" && c.AnchorStr == "" {
   304  		c.AnchorStr = defaults.Config.Imaging.Anchor
   305  		c.Anchor = defaults.Config.Anchor
   306  	}
   307  
   308  	// default to the source format
   309  	if c.TargetFormat == 0 {
   310  		c.TargetFormat = sourceFormat
   311  	}
   312  
   313  	if c.Quality <= 0 && c.TargetFormat.RequiresDefaultQuality() {
   314  		// We need a quality setting for all JPEGs and WEBPs.
   315  		c.Quality = defaults.Config.Imaging.Quality
   316  	}
   317  
   318  	if c.BgColor == nil && c.TargetFormat != sourceFormat {
   319  		if sourceFormat.SupportsTransparency() && !c.TargetFormat.SupportsTransparency() {
   320  			c.BgColor = defaults.Config.BgColor
   321  			c.BgColorStr = defaults.Config.Imaging.BgColor
   322  		}
   323  	}
   324  
   325  	return c, nil
   326  }
   327  
   328  // ImageConfig holds configuration to create a new image from an existing one, resize etc.
   329  type ImageConfig struct {
   330  	// This defines the output format of the output image. It defaults to the source format.
   331  	TargetFormat Format
   332  
   333  	Action string
   334  
   335  	// If set, this will be used as the key in filenames etc.
   336  	Key string
   337  
   338  	// Quality ranges from 1 to 100 inclusive, higher is better.
   339  	// This is only relevant for JPEG and WEBP images.
   340  	// Default is 75.
   341  	Quality            int
   342  	qualitySetForImage bool // Whether the above is set for this image.
   343  
   344  	// Rotate rotates an image by the given angle counter-clockwise.
   345  	// The rotation will be performed first.
   346  	Rotate int
   347  
   348  	// Used to fill any transparency.
   349  	// When set in site config, it's used when converting to a format that does
   350  	// not support transparency.
   351  	// When set per image operation, it's used even for formats that does support
   352  	// transparency.
   353  	BgColor    color.Color
   354  	BgColorStr string
   355  
   356  	// Hint about what type of picture this is. Used to optimize encoding
   357  	// when target is set to webp.
   358  	Hint webpoptions.EncodingPreset
   359  
   360  	Width  int
   361  	Height int
   362  
   363  	Filter    gift.Resampling
   364  	FilterStr string
   365  
   366  	Anchor    gift.Anchor
   367  	AnchorStr string
   368  }
   369  
   370  func (i ImageConfig) GetKey(format Format) string {
   371  	if i.Key != "" {
   372  		return i.Action + "_" + i.Key
   373  	}
   374  
   375  	k := strconv.Itoa(i.Width) + "x" + strconv.Itoa(i.Height)
   376  	if i.Action != "" {
   377  		k += "_" + i.Action
   378  	}
   379  	// This slightly odd construct is here to preserve the old image keys.
   380  	if i.qualitySetForImage || i.TargetFormat.RequiresDefaultQuality() {
   381  		k += "_q" + strconv.Itoa(i.Quality)
   382  	}
   383  	if i.Rotate != 0 {
   384  		k += "_r" + strconv.Itoa(i.Rotate)
   385  	}
   386  	if i.BgColorStr != "" {
   387  		k += "_bg" + i.BgColorStr
   388  	}
   389  
   390  	if i.TargetFormat == WEBP {
   391  		k += "_h" + strconv.Itoa(int(i.Hint))
   392  	}
   393  
   394  	anchor := i.AnchorStr
   395  	if anchor == smartCropIdentifier {
   396  		anchor = anchor + strconv.Itoa(smartCropVersionNumber)
   397  	}
   398  
   399  	k += "_" + i.FilterStr
   400  
   401  	if i.Action == ActionFill || i.Action == ActionCrop {
   402  		k += "_" + anchor
   403  	}
   404  
   405  	if v, ok := imageFormatsVersions[format]; ok {
   406  		k += "_" + strconv.Itoa(v)
   407  	}
   408  
   409  	if mainImageVersionNumber > 0 {
   410  		k += "_" + strconv.Itoa(mainImageVersionNumber)
   411  	}
   412  
   413  	return k
   414  }
   415  
   416  type ImagingConfigInternal struct {
   417  	BgColor        color.Color
   418  	Hint           webpoptions.EncodingPreset
   419  	ResampleFilter gift.Resampling
   420  	Anchor         gift.Anchor
   421  
   422  	Imaging ImagingConfig
   423  }
   424  
   425  func (i *ImagingConfigInternal) Compile(externalCfg *ImagingConfig) error {
   426  	var err error
   427  	i.BgColor, err = hexStringToColor(externalCfg.BgColor)
   428  	if err != nil {
   429  		return err
   430  	}
   431  
   432  	if externalCfg.Anchor != "" && externalCfg.Anchor != smartCropIdentifier {
   433  		anchor, found := anchorPositions[externalCfg.Anchor]
   434  		if !found {
   435  			return fmt.Errorf("invalid anchor value %q in imaging config", i.Anchor)
   436  		}
   437  		i.Anchor = anchor
   438  	}
   439  
   440  	filter, found := imageFilters[externalCfg.ResampleFilter]
   441  	if !found {
   442  		return fmt.Errorf("%q is not a valid resample filter", filter)
   443  	}
   444  	i.ResampleFilter = filter
   445  
   446  	return nil
   447  }
   448  
   449  // ImagingConfig contains default image processing configuration. This will be fetched
   450  // from site (or language) config.
   451  type ImagingConfig struct {
   452  	// Default image quality setting (1-100). Only used for JPEG images.
   453  	Quality int
   454  
   455  	// Resample filter to use in resize operations.
   456  	ResampleFilter string
   457  
   458  	// Hint about what type of image this is.
   459  	// Currently only used when encoding to Webp.
   460  	// Default is "photo".
   461  	// Valid values are "picture", "photo", "drawing", "icon", or "text".
   462  	Hint string
   463  
   464  	// The anchor to use in Fill. Default is "smart", i.e. Smart Crop.
   465  	Anchor string
   466  
   467  	// Default color used in fill operations (e.g. "fff" for white).
   468  	BgColor string
   469  
   470  	Exif ExifConfig
   471  }
   472  
   473  func (cfg *ImagingConfig) init() error {
   474  	if cfg.Quality < 0 || cfg.Quality > 100 {
   475  		return errors.New("image quality must be a number between 1 and 100")
   476  	}
   477  
   478  	cfg.BgColor = strings.ToLower(strings.TrimPrefix(cfg.BgColor, "#"))
   479  	cfg.Anchor = strings.ToLower(cfg.Anchor)
   480  	cfg.ResampleFilter = strings.ToLower(cfg.ResampleFilter)
   481  	cfg.Hint = strings.ToLower(cfg.Hint)
   482  
   483  	if cfg.Anchor == "" {
   484  		cfg.Anchor = smartCropIdentifier
   485  	}
   486  
   487  	if strings.TrimSpace(cfg.Exif.IncludeFields) == "" && strings.TrimSpace(cfg.Exif.ExcludeFields) == "" {
   488  		// Don't change this for no good reason. Please don't.
   489  		cfg.Exif.ExcludeFields = "GPS|Exif|Exposure[M|P|B]|Contrast|Resolution|Sharp|JPEG|Metering|Sensing|Saturation|ColorSpace|Flash|WhiteBalance"
   490  	}
   491  
   492  	return nil
   493  }
   494  
   495  type ExifConfig struct {
   496  	// Regexp matching the Exif fields you want from the (massive) set of Exif info
   497  	// available. As we cache this info to disk, this is for performance and
   498  	// disk space reasons more than anything.
   499  	// If you want it all, put ".*" in this config setting.
   500  	// Note that if neither this or ExcludeFields is set, Hugo will return a small
   501  	// default set.
   502  	IncludeFields string
   503  
   504  	// Regexp matching the Exif fields you want to exclude. This may be easier to use
   505  	// than IncludeFields above, depending on what you want.
   506  	ExcludeFields string
   507  
   508  	// Hugo extracts the "photo taken" date/time into .Date by default.
   509  	// Set this to true to turn it off.
   510  	DisableDate bool
   511  
   512  	// Hugo extracts the "photo taken where" (GPS latitude and longitude) into
   513  	// .Long and .Lat. Set this to true to turn it off.
   514  	DisableLatLong bool
   515  }