github.com/olliephillips/hugo@v0.42.2/resource/image.go (about)

     1  // Copyright 2017-present 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 resource
    15  
    16  import (
    17  	"errors"
    18  	"fmt"
    19  	"image/color"
    20  	"io"
    21  	"os"
    22  	"path/filepath"
    23  	"strconv"
    24  	"strings"
    25  
    26  	"github.com/mitchellh/mapstructure"
    27  
    28  	"github.com/gohugoio/hugo/helpers"
    29  	"github.com/spf13/afero"
    30  
    31  	// Importing image codecs for image.DecodeConfig
    32  	"image"
    33  	"image/draw"
    34  	_ "image/gif"
    35  	"image/jpeg"
    36  	_ "image/png"
    37  
    38  	"github.com/disintegration/imaging"
    39  	// Import webp codec
    40  	"sync"
    41  
    42  	_ "golang.org/x/image/webp"
    43  )
    44  
    45  var (
    46  	_ Resource = (*Image)(nil)
    47  	_ Source   = (*Image)(nil)
    48  	_ Cloner   = (*Image)(nil)
    49  )
    50  
    51  // Imaging contains default image processing configuration. This will be fetched
    52  // from site (or language) config.
    53  type Imaging struct {
    54  	// Default image quality setting (1-100). Only used for JPEG images.
    55  	Quality int
    56  
    57  	// Resample filter used. See https://github.com/disintegration/imaging
    58  	ResampleFilter string
    59  
    60  	// The anchor used in Fill. Default is "smart", i.e. Smart Crop.
    61  	Anchor string
    62  }
    63  
    64  const (
    65  	defaultJPEGQuality    = 75
    66  	defaultResampleFilter = "box"
    67  )
    68  
    69  var (
    70  	imageFormats = map[string]imaging.Format{
    71  		".jpg":  imaging.JPEG,
    72  		".jpeg": imaging.JPEG,
    73  		".png":  imaging.PNG,
    74  		".tif":  imaging.TIFF,
    75  		".tiff": imaging.TIFF,
    76  		".bmp":  imaging.BMP,
    77  		".gif":  imaging.GIF,
    78  	}
    79  
    80  	// Add or increment if changes to an image format's processing requires
    81  	// re-generation.
    82  	imageFormatsVersions = map[imaging.Format]int{
    83  		imaging.PNG: 2, // Floyd Steinberg dithering
    84  	}
    85  
    86  	// Increment to mark all processed images as stale. Only use when absolutely needed.
    87  	// See the finer grained smartCropVersionNumber and imageFormatsVersions.
    88  	mainImageVersionNumber = 0
    89  )
    90  
    91  var anchorPositions = map[string]imaging.Anchor{
    92  	strings.ToLower("Center"):      imaging.Center,
    93  	strings.ToLower("TopLeft"):     imaging.TopLeft,
    94  	strings.ToLower("Top"):         imaging.Top,
    95  	strings.ToLower("TopRight"):    imaging.TopRight,
    96  	strings.ToLower("Left"):        imaging.Left,
    97  	strings.ToLower("Right"):       imaging.Right,
    98  	strings.ToLower("BottomLeft"):  imaging.BottomLeft,
    99  	strings.ToLower("Bottom"):      imaging.Bottom,
   100  	strings.ToLower("BottomRight"): imaging.BottomRight,
   101  }
   102  
   103  var imageFilters = map[string]imaging.ResampleFilter{
   104  	strings.ToLower("NearestNeighbor"):   imaging.NearestNeighbor,
   105  	strings.ToLower("Box"):               imaging.Box,
   106  	strings.ToLower("Linear"):            imaging.Linear,
   107  	strings.ToLower("Hermite"):           imaging.Hermite,
   108  	strings.ToLower("MitchellNetravali"): imaging.MitchellNetravali,
   109  	strings.ToLower("CatmullRom"):        imaging.CatmullRom,
   110  	strings.ToLower("BSpline"):           imaging.BSpline,
   111  	strings.ToLower("Gaussian"):          imaging.Gaussian,
   112  	strings.ToLower("Lanczos"):           imaging.Lanczos,
   113  	strings.ToLower("Hann"):              imaging.Hann,
   114  	strings.ToLower("Hamming"):           imaging.Hamming,
   115  	strings.ToLower("Blackman"):          imaging.Blackman,
   116  	strings.ToLower("Bartlett"):          imaging.Bartlett,
   117  	strings.ToLower("Welch"):             imaging.Welch,
   118  	strings.ToLower("Cosine"):            imaging.Cosine,
   119  }
   120  
   121  type Image struct {
   122  	config       image.Config
   123  	configInit   sync.Once
   124  	configLoaded bool
   125  
   126  	copyToDestinationInit sync.Once
   127  
   128  	// Lock used when creating alternate versions of this image.
   129  	createMu sync.Mutex
   130  
   131  	imaging *Imaging
   132  
   133  	format imaging.Format
   134  
   135  	hash string
   136  
   137  	*genericResource
   138  }
   139  
   140  func (i *Image) Width() int {
   141  	i.initConfig()
   142  	return i.config.Width
   143  }
   144  
   145  func (i *Image) Height() int {
   146  	i.initConfig()
   147  	return i.config.Height
   148  }
   149  
   150  // Implement the Cloner interface.
   151  func (i *Image) WithNewBase(base string) Resource {
   152  	return &Image{
   153  		imaging:         i.imaging,
   154  		hash:            i.hash,
   155  		format:          i.format,
   156  		genericResource: i.genericResource.WithNewBase(base).(*genericResource)}
   157  }
   158  
   159  // Resize resizes the image to the specified width and height using the specified resampling
   160  // filter and returns the transformed image. If one of width or height is 0, the image aspect
   161  // ratio is preserved.
   162  func (i *Image) Resize(spec string) (*Image, error) {
   163  	return i.doWithImageConfig("resize", spec, func(src image.Image, conf imageConfig) (image.Image, error) {
   164  		return imaging.Resize(src, conf.Width, conf.Height, conf.Filter), nil
   165  	})
   166  }
   167  
   168  // Fit scales down the image using the specified resample filter to fit the specified
   169  // maximum width and height.
   170  func (i *Image) Fit(spec string) (*Image, error) {
   171  	return i.doWithImageConfig("fit", spec, func(src image.Image, conf imageConfig) (image.Image, error) {
   172  		return imaging.Fit(src, conf.Width, conf.Height, conf.Filter), nil
   173  	})
   174  }
   175  
   176  // Fill scales the image to the smallest possible size that will cover the specified dimensions,
   177  // crops the resized image to the specified dimensions using the given anchor point.
   178  // Space delimited config: 200x300 TopLeft
   179  func (i *Image) Fill(spec string) (*Image, error) {
   180  	return i.doWithImageConfig("fill", spec, func(src image.Image, conf imageConfig) (image.Image, error) {
   181  		if conf.AnchorStr == smartCropIdentifier {
   182  			return smartCrop(src, conf.Width, conf.Height, conf.Anchor, conf.Filter)
   183  		}
   184  		return imaging.Fill(src, conf.Width, conf.Height, conf.Anchor, conf.Filter), nil
   185  	})
   186  }
   187  
   188  // Holds configuration to create a new image from an existing one, resize etc.
   189  type imageConfig struct {
   190  	Action string
   191  
   192  	// Quality ranges from 1 to 100 inclusive, higher is better.
   193  	// This is only relevant for JPEG images.
   194  	// Default is 75.
   195  	Quality int
   196  
   197  	// Rotate rotates an image by the given angle counter-clockwise.
   198  	// The rotation will be performed first.
   199  	Rotate int
   200  
   201  	Width  int
   202  	Height int
   203  
   204  	Filter    imaging.ResampleFilter
   205  	FilterStr string
   206  
   207  	Anchor    imaging.Anchor
   208  	AnchorStr string
   209  }
   210  
   211  func (i *Image) isJPEG() bool {
   212  	name := strings.ToLower(i.relTargetPath.file)
   213  	return strings.HasSuffix(name, ".jpg") || strings.HasSuffix(name, ".jpeg")
   214  }
   215  
   216  func (i *Image) doWithImageConfig(action, spec string, f func(src image.Image, conf imageConfig) (image.Image, error)) (*Image, error) {
   217  	conf, err := parseImageConfig(spec)
   218  	if err != nil {
   219  		return nil, err
   220  	}
   221  	conf.Action = action
   222  
   223  	if conf.Quality <= 0 && i.isJPEG() {
   224  		// We need a quality setting for all JPEGs
   225  		conf.Quality = i.imaging.Quality
   226  	}
   227  
   228  	if conf.FilterStr == "" {
   229  		conf.FilterStr = i.imaging.ResampleFilter
   230  		conf.Filter = imageFilters[conf.FilterStr]
   231  	}
   232  
   233  	if conf.AnchorStr == "" {
   234  		conf.AnchorStr = i.imaging.Anchor
   235  		if !strings.EqualFold(conf.AnchorStr, smartCropIdentifier) {
   236  			conf.Anchor = anchorPositions[conf.AnchorStr]
   237  		}
   238  	}
   239  
   240  	return i.spec.imageCache.getOrCreate(i, conf, func(resourceCacheFilename string) (*Image, error) {
   241  		ci := i.clone()
   242  
   243  		errOp := action
   244  		errPath := i.AbsSourceFilename()
   245  
   246  		ci.setBasePath(conf)
   247  
   248  		src, err := i.decodeSource()
   249  		if err != nil {
   250  			return nil, &os.PathError{Op: errOp, Path: errPath, Err: err}
   251  		}
   252  
   253  		if conf.Rotate != 0 {
   254  			// Rotate it before any scaling to get the dimensions correct.
   255  			src = imaging.Rotate(src, float64(conf.Rotate), color.Transparent)
   256  		}
   257  
   258  		converted, err := f(src, conf)
   259  		if err != nil {
   260  			return ci, &os.PathError{Op: errOp, Path: errPath, Err: err}
   261  		}
   262  
   263  		if i.format == imaging.PNG {
   264  			// Apply the colour palette from the source
   265  			if paletted, ok := src.(*image.Paletted); ok {
   266  				tmp := image.NewPaletted(converted.Bounds(), paletted.Palette)
   267  				draw.FloydSteinberg.Draw(tmp, tmp.Bounds(), converted, converted.Bounds().Min)
   268  				converted = tmp
   269  			}
   270  		}
   271  
   272  		b := converted.Bounds()
   273  		ci.config = image.Config{Width: b.Max.X, Height: b.Max.Y}
   274  		ci.configLoaded = true
   275  
   276  		return ci, i.encodeToDestinations(converted, conf, resourceCacheFilename, ci.target())
   277  	})
   278  
   279  }
   280  
   281  func (i imageConfig) key(format imaging.Format) string {
   282  	k := strconv.Itoa(i.Width) + "x" + strconv.Itoa(i.Height)
   283  	if i.Action != "" {
   284  		k += "_" + i.Action
   285  	}
   286  	if i.Quality > 0 {
   287  		k += "_q" + strconv.Itoa(i.Quality)
   288  	}
   289  	if i.Rotate != 0 {
   290  		k += "_r" + strconv.Itoa(i.Rotate)
   291  	}
   292  	anchor := i.AnchorStr
   293  	if anchor == smartCropIdentifier {
   294  		anchor = anchor + strconv.Itoa(smartCropVersionNumber)
   295  	}
   296  
   297  	k += "_" + i.FilterStr
   298  
   299  	if strings.EqualFold(i.Action, "fill") {
   300  		k += "_" + anchor
   301  	}
   302  
   303  	if v, ok := imageFormatsVersions[format]; ok {
   304  		k += "_" + strconv.Itoa(v)
   305  	}
   306  
   307  	if mainImageVersionNumber > 0 {
   308  		k += "_" + strconv.Itoa(mainImageVersionNumber)
   309  	}
   310  
   311  	return k
   312  }
   313  
   314  func newImageConfig(width, height, quality, rotate int, filter, anchor string) imageConfig {
   315  	var c imageConfig
   316  
   317  	c.Width = width
   318  	c.Height = height
   319  	c.Quality = quality
   320  	c.Rotate = rotate
   321  
   322  	if filter != "" {
   323  		filter = strings.ToLower(filter)
   324  		if v, ok := imageFilters[filter]; ok {
   325  			c.Filter = v
   326  			c.FilterStr = filter
   327  		}
   328  	}
   329  
   330  	if anchor != "" {
   331  		anchor = strings.ToLower(anchor)
   332  		if v, ok := anchorPositions[anchor]; ok {
   333  			c.Anchor = v
   334  			c.AnchorStr = anchor
   335  		}
   336  	}
   337  
   338  	return c
   339  }
   340  
   341  func parseImageConfig(config string) (imageConfig, error) {
   342  	var (
   343  		c   imageConfig
   344  		err error
   345  	)
   346  
   347  	if config == "" {
   348  		return c, errors.New("image config cannot be empty")
   349  	}
   350  
   351  	parts := strings.Fields(config)
   352  	for _, part := range parts {
   353  		part = strings.ToLower(part)
   354  
   355  		if part == smartCropIdentifier {
   356  			c.AnchorStr = smartCropIdentifier
   357  		} else if pos, ok := anchorPositions[part]; ok {
   358  			c.Anchor = pos
   359  			c.AnchorStr = part
   360  		} else if filter, ok := imageFilters[part]; ok {
   361  			c.Filter = filter
   362  			c.FilterStr = part
   363  		} else if part[0] == 'q' {
   364  			c.Quality, err = strconv.Atoi(part[1:])
   365  			if err != nil {
   366  				return c, err
   367  			}
   368  			if c.Quality < 1 && c.Quality > 100 {
   369  				return c, errors.New("quality ranges from 1 to 100 inclusive")
   370  			}
   371  		} else if part[0] == 'r' {
   372  			c.Rotate, err = strconv.Atoi(part[1:])
   373  			if err != nil {
   374  				return c, err
   375  			}
   376  		} else if strings.Contains(part, "x") {
   377  			widthHeight := strings.Split(part, "x")
   378  			if len(widthHeight) <= 2 {
   379  				first := widthHeight[0]
   380  				if first != "" {
   381  					c.Width, err = strconv.Atoi(first)
   382  					if err != nil {
   383  						return c, err
   384  					}
   385  				}
   386  
   387  				if len(widthHeight) == 2 {
   388  					second := widthHeight[1]
   389  					if second != "" {
   390  						c.Height, err = strconv.Atoi(second)
   391  						if err != nil {
   392  							return c, err
   393  						}
   394  					}
   395  				}
   396  			} else {
   397  				return c, errors.New("invalid image dimensions")
   398  			}
   399  
   400  		}
   401  	}
   402  
   403  	if c.Width == 0 && c.Height == 0 {
   404  		return c, errors.New("must provide Width or Height")
   405  	}
   406  
   407  	return c, nil
   408  }
   409  
   410  func (i *Image) initConfig() error {
   411  	var err error
   412  	i.configInit.Do(func() {
   413  		if i.configLoaded {
   414  			return
   415  		}
   416  
   417  		var (
   418  			f      afero.File
   419  			config image.Config
   420  		)
   421  
   422  		f, err = i.sourceFs().Open(i.AbsSourceFilename())
   423  		if err != nil {
   424  			return
   425  		}
   426  		defer f.Close()
   427  
   428  		config, _, err = image.DecodeConfig(f)
   429  		if err != nil {
   430  			return
   431  		}
   432  		i.config = config
   433  	})
   434  
   435  	if err != nil {
   436  		return fmt.Errorf("failed to load image config: %s", err)
   437  	}
   438  
   439  	return nil
   440  }
   441  
   442  func (i *Image) decodeSource() (image.Image, error) {
   443  	file, err := i.sourceFs().Open(i.AbsSourceFilename())
   444  	if err != nil {
   445  		return nil, fmt.Errorf("failed to open image for decode: %s", err)
   446  	}
   447  	defer file.Close()
   448  	img, _, err := image.Decode(file)
   449  	return img, err
   450  }
   451  
   452  func (i *Image) copyToDestination(src string) error {
   453  	var res error
   454  	i.copyToDestinationInit.Do(func() {
   455  		target := i.target()
   456  
   457  		// Fast path:
   458  		// This is a processed version of the original.
   459  		// If it exists on destination with the same filename and file size, it is
   460  		// the same file, so no need to transfer it again.
   461  		if fi, err := i.spec.BaseFs.PublishFs.Stat(target); err == nil && fi.Size() == i.osFileInfo.Size() {
   462  			return
   463  		}
   464  
   465  		in, err := i.sourceFs().Open(src)
   466  		if err != nil {
   467  			res = err
   468  			return
   469  		}
   470  		defer in.Close()
   471  
   472  		out, err := i.spec.BaseFs.PublishFs.Create(target)
   473  		if err != nil && os.IsNotExist(err) {
   474  			// When called from shortcodes, the target directory may not exist yet.
   475  			// See https://github.com/gohugoio/hugo/issues/4202
   476  			if err = i.spec.BaseFs.PublishFs.MkdirAll(filepath.Dir(target), os.FileMode(0755)); err != nil {
   477  				res = err
   478  				return
   479  			}
   480  			out, err = i.spec.BaseFs.PublishFs.Create(target)
   481  			if err != nil {
   482  				res = err
   483  				return
   484  			}
   485  		} else if err != nil {
   486  			res = err
   487  			return
   488  		}
   489  		defer out.Close()
   490  
   491  		_, err = io.Copy(out, in)
   492  		if err != nil {
   493  			res = err
   494  			return
   495  		}
   496  	})
   497  
   498  	if res != nil {
   499  		return fmt.Errorf("failed to copy image to destination: %s", res)
   500  	}
   501  	return nil
   502  }
   503  
   504  func (i *Image) encodeToDestinations(img image.Image, conf imageConfig, resourceCacheFilename, filename string) error {
   505  	target := filepath.Clean(filename)
   506  
   507  	file1, err := i.spec.BaseFs.PublishFs.Create(target)
   508  	if err != nil && os.IsNotExist(err) {
   509  		// When called from shortcodes, the target directory may not exist yet.
   510  		// See https://github.com/gohugoio/hugo/issues/4202
   511  		if err = i.spec.BaseFs.PublishFs.MkdirAll(filepath.Dir(target), os.FileMode(0755)); err != nil {
   512  			return err
   513  		}
   514  		file1, err = i.spec.BaseFs.PublishFs.Create(target)
   515  		if err != nil {
   516  			return err
   517  		}
   518  	} else if err != nil {
   519  		return err
   520  	}
   521  
   522  	defer file1.Close()
   523  
   524  	var w io.Writer
   525  
   526  	if resourceCacheFilename != "" {
   527  		// Also save it to the image resource cache for later reuse.
   528  		if err = i.spec.BaseFs.ResourcesFs.MkdirAll(filepath.Dir(resourceCacheFilename), os.FileMode(0755)); err != nil {
   529  			return err
   530  		}
   531  
   532  		file2, err := i.spec.BaseFs.ResourcesFs.Create(resourceCacheFilename)
   533  		if err != nil {
   534  			return err
   535  		}
   536  
   537  		w = io.MultiWriter(file1, file2)
   538  		defer file2.Close()
   539  	} else {
   540  		w = file1
   541  	}
   542  
   543  	switch i.format {
   544  	case imaging.JPEG:
   545  
   546  		var rgba *image.RGBA
   547  		quality := conf.Quality
   548  
   549  		if nrgba, ok := img.(*image.NRGBA); ok {
   550  			if nrgba.Opaque() {
   551  				rgba = &image.RGBA{
   552  					Pix:    nrgba.Pix,
   553  					Stride: nrgba.Stride,
   554  					Rect:   nrgba.Rect,
   555  				}
   556  			}
   557  		}
   558  		if rgba != nil {
   559  			return jpeg.Encode(w, rgba, &jpeg.Options{Quality: quality})
   560  		} else {
   561  			return jpeg.Encode(w, img, &jpeg.Options{Quality: quality})
   562  		}
   563  	default:
   564  		return imaging.Encode(w, img, i.format)
   565  	}
   566  
   567  }
   568  
   569  func (i *Image) clone() *Image {
   570  	g := *i.genericResource
   571  	g.resourceContent = &resourceContent{}
   572  
   573  	return &Image{
   574  		imaging:         i.imaging,
   575  		hash:            i.hash,
   576  		format:          i.format,
   577  		genericResource: &g}
   578  }
   579  
   580  func (i *Image) setBasePath(conf imageConfig) {
   581  	i.relTargetPath = i.relTargetPathFromConfig(conf)
   582  }
   583  
   584  func (i *Image) relTargetPathFromConfig(conf imageConfig) dirFile {
   585  	p1, p2 := helpers.FileAndExt(i.relTargetPath.file)
   586  
   587  	idStr := fmt.Sprintf("_hu%s_%d", i.hash, i.osFileInfo.Size())
   588  
   589  	// Do not change for no good reason.
   590  	const md5Threshold = 100
   591  
   592  	key := conf.key(i.format)
   593  
   594  	// It is useful to have the key in clear text, but when nesting transforms, it
   595  	// can easily be too long to read, and maybe even too long
   596  	// for the different OSes to handle.
   597  	if len(p1)+len(idStr)+len(p2) > md5Threshold {
   598  		key = helpers.MD5String(p1 + key + p2)
   599  		huIdx := strings.Index(p1, "_hu")
   600  		if huIdx != -1 {
   601  			p1 = p1[:huIdx]
   602  		} else {
   603  			// This started out as a very long file name. Making it even longer
   604  			// could melt ice in the Arctic.
   605  			p1 = ""
   606  		}
   607  	} else if strings.Contains(p1, idStr) {
   608  		// On scaling an already scaled image, we get the file info from the original.
   609  		// Repeating the same info in the filename makes it stuttery for no good reason.
   610  		idStr = ""
   611  	}
   612  
   613  	return dirFile{
   614  		dir:  i.relTargetPath.dir,
   615  		file: fmt.Sprintf("%s%s_%s%s", p1, idStr, key, p2),
   616  	}
   617  
   618  }
   619  
   620  func decodeImaging(m map[string]interface{}) (Imaging, error) {
   621  	var i Imaging
   622  	if err := mapstructure.WeakDecode(m, &i); err != nil {
   623  		return i, err
   624  	}
   625  
   626  	if i.Quality == 0 {
   627  		i.Quality = defaultJPEGQuality
   628  	} else if i.Quality < 0 || i.Quality > 100 {
   629  		return i, errors.New("JPEG quality must be a number between 1 and 100")
   630  	}
   631  
   632  	if i.Anchor == "" || strings.EqualFold(i.Anchor, smartCropIdentifier) {
   633  		i.Anchor = smartCropIdentifier
   634  	} else {
   635  		i.Anchor = strings.ToLower(i.Anchor)
   636  		if _, found := anchorPositions[i.Anchor]; !found {
   637  			return i, errors.New("invalid anchor value in imaging config")
   638  		}
   639  	}
   640  
   641  	if i.ResampleFilter == "" {
   642  		i.ResampleFilter = defaultResampleFilter
   643  	} else {
   644  		filter := strings.ToLower(i.ResampleFilter)
   645  		_, found := imageFilters[filter]
   646  		if !found {
   647  			return i, fmt.Errorf("%q is not a valid resample filter", filter)
   648  		}
   649  		i.ResampleFilter = filter
   650  	}
   651  
   652  	return i, nil
   653  }