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