diff options
author | Bjørn Erik Pedersen <bjorn.erik.pedersen@gmail.com> | 2019-08-18 11:21:27 +0200 |
---|---|---|
committer | Bjørn Erik Pedersen <bjorn.erik.pedersen@gmail.com> | 2019-08-26 15:00:44 +0200 |
commit | f9978ed16476ca6d233a89669c62c798cdf9db9d (patch) | |
tree | 02edb31008b997a3e77055060a34971fe9e8c5a4 /resources | |
parent | 58d4c0a8be8beefbd7437b17bf7a9a381164d09b (diff) |
Image resource refactor
This commit pulls most of the image related logic into its own package, to make it easier to reason about and extend.
This is also a rewrite of the transformation logic used in Hugo Pipes, mostly to allow constructs like the one below:
{{ ($myimg | fingerprint ).Width }}
Fixes #5903
Fixes #6234
Fixes #6266
Diffstat (limited to 'resources')
27 files changed, 2492 insertions, 1456 deletions
diff --git a/resources/image.go b/resources/image.go index f1aae2996..e1a816942 100644 --- a/resources/image.go +++ b/resources/image.go @@ -14,198 +14,98 @@ package resources import ( - "errors" "fmt" "image" "image/color" "image/draw" - "image/jpeg" - "io" + _ "image/gif" + _ "image/png" "os" - "strconv" "strings" - "sync" "github.com/gohugoio/hugo/resources/resource" _errors "github.com/pkg/errors" "github.com/disintegration/imaging" - "github.com/gohugoio/hugo/common/hugio" "github.com/gohugoio/hugo/helpers" - "github.com/mitchellh/mapstructure" + "github.com/gohugoio/hugo/resources/images" // Blind import for image.Decode - _ "image/gif" - _ "image/png" // Blind import for image.Decode _ "golang.org/x/image/webp" ) var ( - _ resource.Resource = (*Image)(nil) - _ resource.Source = (*Image)(nil) - _ resource.Cloner = (*Image)(nil) + _ resource.Image = (*imageResource)(nil) + _ resource.Source = (*imageResource)(nil) + _ resource.Cloner = (*imageResource)(nil) ) -// Imaging contains default image processing configuration. This will be fetched -// from site (or language) config. -type Imaging struct { - // Default image quality setting (1-100). Only used for JPEG images. - Quality int - - // Resample filter used. See https://github.com/disintegration/imaging - ResampleFilter string +// ImageResource represents an image resource. +type imageResource struct { + *images.Image - // The anchor used in Fill. Default is "smart", i.e. Smart Crop. - Anchor string + baseResource } -const ( - defaultJPEGQuality = 75 - defaultResampleFilter = "box" -) - -var ( - imageFormats = map[string]imaging.Format{ - ".jpg": imaging.JPEG, - ".jpeg": imaging.JPEG, - ".png": imaging.PNG, - ".tif": imaging.TIFF, - ".tiff": imaging.TIFF, - ".bmp": imaging.BMP, - ".gif": imaging.GIF, +func (i *imageResource) Clone() resource.Resource { + gr := i.baseResource.Clone().(baseResource) + return &imageResource{ + Image: i.WithSpec(gr), + baseResource: gr, } - - // Add or increment if changes to an image format's processing requires - // re-generation. - imageFormatsVersions = map[imaging.Format]int{ - imaging.PNG: 2, // Floyd Steinberg dithering - } - - // Increment to mark all processed images as stale. Only use when absolutely needed. - // See the finer grained smartCropVersionNumber and imageFormatsVersions. - mainImageVersionNumber = 0 -) - -var anchorPositions = map[string]imaging.Anchor{ - strings.ToLower("Center"): imaging.Center, - strings.ToLower("TopLeft"): imaging.TopLeft, - strings.ToLower("Top"): imaging.Top, - strings.ToLower("TopRight"): imaging.TopRight, - strings.ToLower("Left"): imaging.Left, - strings.ToLower("Right"): imaging.Right, - strings.ToLower("BottomLeft"): imaging.BottomLeft, - strings.ToLower("Bottom"): imaging.Bottom, - strings.ToLower("BottomRight"): imaging.BottomRight, } -var imageFilters = map[string]imaging.ResampleFilter{ - strings.ToLower("NearestNeighbor"): imaging.NearestNeighbor, - strings.ToLower("Box"): imaging.Box, - strings.ToLower("Linear"): imaging.Linear, - strings.ToLower("Hermite"): imaging.Hermite, - strings.ToLower("MitchellNetravali"): imaging.MitchellNetravali, - strings.ToLower("CatmullRom"): imaging.CatmullRom, - strings.ToLower("BSpline"): imaging.BSpline, - strings.ToLower("Gaussian"): imaging.Gaussian, - strings.ToLower("Lanczos"): imaging.Lanczos, - strings.ToLower("Hann"): imaging.Hann, - strings.ToLower("Hamming"): imaging.Hamming, - strings.ToLower("Blackman"): imaging.Blackman, - strings.ToLower("Bartlett"): imaging.Bartlett, - strings.ToLower("Welch"): imaging.Welch, - strings.ToLower("Cosine"): imaging.Cosine, -} - -// Image represents an image resource. -type Image struct { - config image.Config - configInit sync.Once - configLoaded bool - - imaging *Imaging - - format imaging.Format - - *genericResource -} +func (i *imageResource) cloneWithUpdates(u *transformationUpdate) (baseResource, error) { + base, err := i.baseResource.cloneWithUpdates(u) + if err != nil { + return nil, err + } -// Width returns i's width. -func (i *Image) Width() int { - i.initConfig() - return i.config.Width -} + var img *images.Image -// Height returns i's height. -func (i *Image) Height() int { - i.initConfig() - return i.config.Height -} + if u.isContenChanged() { + img = i.WithSpec(base) + } else { + img = i.Image + } -// WithNewBase implements the Cloner interface. -func (i *Image) WithNewBase(base string) resource.Resource { - return &Image{ - imaging: i.imaging, - format: i.format, - genericResource: i.genericResource.WithNewBase(base).(*genericResource)} + return &imageResource{ + Image: img, + baseResource: base, + }, nil } // Resize resizes the image to the specified width and height using the specified resampling // filter and returns the transformed image. If one of width or height is 0, the image aspect // ratio is preserved. -func (i *Image) Resize(spec string) (*Image, error) { - return i.doWithImageConfig("resize", spec, func(src image.Image, conf imageConfig) (image.Image, error) { - return imaging.Resize(src, conf.Width, conf.Height, conf.Filter), nil +func (i *imageResource) Resize(spec string) (resource.Image, error) { + return i.doWithImageConfig("resize", spec, func(src image.Image, conf images.ImageConfig) (image.Image, error) { + return i.Proc.Resize(src, conf) }) } // Fit scales down the image using the specified resample filter to fit the specified // maximum width and height. -func (i *Image) Fit(spec string) (*Image, error) { - return i.doWithImageConfig("fit", spec, func(src image.Image, conf imageConfig) (image.Image, error) { - return imaging.Fit(src, conf.Width, conf.Height, conf.Filter), nil +func (i *imageResource) Fit(spec string) (resource.Image, error) { + return i.doWithImageConfig("fit", spec, func(src image.Image, conf images.ImageConfig) (image.Image, error) { + return i.Proc.Fit(src, conf) }) } // Fill scales the image to the smallest possible size that will cover the specified dimensions, // crops the resized image to the specified dimensions using the given anchor point. // Space delimited config: 200x300 TopLeft -func (i *Image) Fill(spec string) (*Image, error) { - return i.doWithImageConfig("fill", spec, func(src image.Image, conf imageConfig) (image.Image, error) { - if conf.AnchorStr == smartCropIdentifier { - return smartCrop(src, conf.Width, conf.Height, conf.Anchor, conf.Filter) - } - return imaging.Fill(src, conf.Width, conf.Height, conf.Anchor, conf.Filter), nil +func (i *imageResource) Fill(spec string) (resource.Image, error) { + return i.doWithImageConfig("fill", spec, func(src image.Image, conf images.ImageConfig) (image.Image, error) { + return i.Proc.Fill(src, conf) }) } -// Holds configuration to create a new image from an existing one, resize etc. -type imageConfig struct { - Action string - - // Quality ranges from 1 to 100 inclusive, higher is better. - // This is only relevant for JPEG images. - // Default is 75. - Quality int - - // Rotate rotates an image by the given angle counter-clockwise. - // The rotation will be performed first. - Rotate int - - Width int - Height int - - Filter imaging.ResampleFilter - FilterStr string - - Anchor imaging.Anchor - AnchorStr string -} - -func (i *Image) isJPEG() bool { - name := strings.ToLower(i.relTargetDirFile.file) +func (i *imageResource) isJPEG() bool { + name := strings.ToLower(i.getResourcePaths().relTargetDirFile.file) return strings.HasSuffix(name, ".jpg") || strings.HasSuffix(name, ".jpeg") } @@ -218,42 +118,20 @@ const imageProcWorkers = 1 var imageProcSem = make(chan bool, imageProcWorkers) -func (i *Image) doWithImageConfig(action, spec string, f func(src image.Image, conf imageConfig) (image.Image, error)) (*Image, error) { - conf, err := parseImageConfig(spec) +func (i *imageResource) doWithImageConfig(action, spec string, f func(src image.Image, conf images.ImageConfig) (image.Image, error)) (resource.Image, error) { + conf, err := i.decodeImageConfig(action, spec) if err != nil { return nil, err } - conf.Action = action - - if conf.Quality <= 0 && i.isJPEG() { - // We need a quality setting for all JPEGs - conf.Quality = i.imaging.Quality - } - - if conf.FilterStr == "" { - conf.FilterStr = i.imaging.ResampleFilter - conf.Filter = imageFilters[conf.FilterStr] - } - - if conf.AnchorStr == "" { - conf.AnchorStr = i.imaging.Anchor - if !strings.EqualFold(conf.AnchorStr, smartCropIdentifier) { - conf.Anchor = anchorPositions[conf.AnchorStr] - } - } - return i.spec.imageCache.getOrCreate(i, conf, func() (*Image, image.Image, error) { + return i.getSpec().imageCache.getOrCreate(i, conf, func() (*imageResource, image.Image, error) { imageProcSem <- true defer func() { <-imageProcSem }() - ci := i.clone() - errOp := action - errPath := i.sourceFilename - - ci.setBasePath(conf) + errPath := i.getSourceFilename() src, err := i.decodeSource() if err != nil { @@ -267,10 +145,10 @@ func (i *Image) doWithImageConfig(action, spec string, f func(src image.Image, c converted, err := f(src, conf) if err != nil { - return ci, nil, &os.PathError{Op: errOp, Path: errPath, Err: err} + return nil, nil, &os.PathError{Op: errOp, Path: errPath, Err: err} } - if i.format == imaging.PNG { + if i.Format == imaging.PNG { // Apply the colour palette from the source if paletted, ok := src.(*image.Paletted); ok { tmp := image.NewPaletted(converted.Bounds(), paletted.Palette) @@ -279,177 +157,30 @@ func (i *Image) doWithImageConfig(action, spec string, f func(src image.Image, c } } - b := converted.Bounds() - ci.config = image.Config{Width: b.Max.X, Height: b.Max.Y} - ci.configLoaded = true + ci := i.clone(converted) + ci.setBasePath(conf) return ci, converted, nil }) - -} - -func (i imageConfig) key(format imaging.Format) string { - k := strconv.Itoa(i.Width) + "x" + strconv.Itoa(i.Height) - if i.Action != "" { - k += "_" + i.Action - } - if i.Quality > 0 { - k += "_q" + strconv.Itoa(i.Quality) - } - if i.Rotate != 0 { - k += "_r" + strconv.Itoa(i.Rotate) - } - anchor := i.AnchorStr - if anchor == smartCropIdentifier { - anchor = anchor + strconv.Itoa(smartCropVersionNumber) - } - - k += "_" + i.FilterStr - - if strings.EqualFold(i.Action, "fill") { - k += "_" + anchor - } - - if v, ok := imageFormatsVersions[format]; ok { - k += "_" + strconv.Itoa(v) - } - - if mainImageVersionNumber > 0 { - k += "_" + strconv.Itoa(mainImageVersionNumber) - } - - return k -} - -func newImageConfig(width, height, quality, rotate int, filter, anchor string) imageConfig { - var c imageConfig - - c.Width = width - c.Height = height - c.Quality = quality - c.Rotate = rotate - - if filter != "" { - filter = strings.ToLower(filter) - if v, ok := imageFilters[filter]; ok { - c.Filter = v - c.FilterStr = filter - } - } - - if anchor != "" { - anchor = strings.ToLower(anchor) - if v, ok := anchorPositions[anchor]; ok { - c.Anchor = v - c.AnchorStr = anchor - } - } - - return c } -func parseImageConfig(config string) (imageConfig, error) { - var ( - c imageConfig - err error - ) - - if config == "" { - return c, errors.New("image config cannot be empty") - } - - parts := strings.Fields(config) - for _, part := range parts { - part = strings.ToLower(part) - - if part == smartCropIdentifier { - c.AnchorStr = smartCropIdentifier - } else if pos, ok := anchorPositions[part]; ok { - c.Anchor = pos - c.AnchorStr = part - } else if filter, ok := imageFilters[part]; ok { - c.Filter = filter - c.FilterStr = part - } else if part[0] == 'q' { - c.Quality, err = strconv.Atoi(part[1:]) - if err != nil { - return c, err - } - if c.Quality < 1 || c.Quality > 100 { - return c, errors.New("quality ranges from 1 to 100 inclusive") - } - } else if part[0] == 'r' { - c.Rotate, err = strconv.Atoi(part[1:]) - if err != nil { - return c, err - } - } else if strings.Contains(part, "x") { - widthHeight := strings.Split(part, "x") - if len(widthHeight) <= 2 { - first := widthHeight[0] - if first != "" { - c.Width, err = strconv.Atoi(first) - if err != nil { - return c, err - } - } - - if len(widthHeight) == 2 { - second := widthHeight[1] - if second != "" { - c.Height, err = strconv.Atoi(second) - if err != nil { - return c, err - } - } - } - } else { - return c, errors.New("invalid image dimensions") - } - - } - } - - if c.Width == 0 && c.Height == 0 { - return c, errors.New("must provide Width or Height") +func (i *imageResource) decodeImageConfig(action, spec string) (images.ImageConfig, error) { + conf, err := images.DecodeImageConfig(action, spec, i.Proc.Cfg) + if err != nil { + return conf, err } - return c, nil -} - -func (i *Image) initConfig() error { - var err error - i.configInit.Do(func() { - if i.configLoaded { - return - } - - var ( - f hugio.ReadSeekCloser - config image.Config - ) - - f, err = i.ReadSeekCloser() - if err != nil { - return - } - defer f.Close() - - config, _, err = image.DecodeConfig(f) - if err != nil { - return - } - i.config = config - }) + iconf := i.Proc.Cfg - if err != nil { - return _errors.Wrap(err, "failed to load image config") + if conf.Quality <= 0 && i.isJPEG() { + // We need a quality setting for all JPEGs + conf.Quality = iconf.Quality } - return nil + return conf, nil } -func (i *Image) decodeSource() (image.Image, error) { +func (i *imageResource) decodeSource() (image.Image, error) { f, err := i.ReadSeekCloser() if err != nil { return nil, _errors.Wrap(err, "failed to open image for decode") @@ -459,80 +190,39 @@ func (i *Image) decodeSource() (image.Image, error) { return img, err } -// returns an opened file or nil if nothing to write. -func (i *Image) openDestinationsForWriting() (io.WriteCloser, error) { - targetFilenames := i.targetFilenames() - var changedFilenames []string - - // Fast path: - // This is a processed version of the original; - // check if it already existis at the destination. - for _, targetFilename := range targetFilenames { - if _, err := i.spec.BaseFs.PublishFs.Stat(targetFilename); err == nil { - continue - } - changedFilenames = append(changedFilenames, targetFilename) - } - - if len(changedFilenames) == 0 { - return nil, nil - } - - return helpers.OpenFilesForWriting(i.spec.BaseFs.PublishFs, changedFilenames...) - -} - -func (i *Image) encodeTo(conf imageConfig, img image.Image, w io.Writer) error { - switch i.format { - case imaging.JPEG: - - var rgba *image.RGBA - quality := conf.Quality +func (i *imageResource) clone(img image.Image) *imageResource { + spec := i.baseResource.Clone().(baseResource) - if nrgba, ok := img.(*image.NRGBA); ok { - if nrgba.Opaque() { - rgba = &image.RGBA{ - Pix: nrgba.Pix, - Stride: nrgba.Stride, - Rect: nrgba.Rect, - } - } - } - if rgba != nil { - return jpeg.Encode(w, rgba, &jpeg.Options{Quality: quality}) - } - return jpeg.Encode(w, img, &jpeg.Options{Quality: quality}) - default: - return imaging.Encode(w, img, i.format) + var image *images.Image + if img != nil { + image = i.WithImage(img) + } else { + image = i.WithSpec(spec) } -} -func (i *Image) clone() *Image { - g := *i.genericResource - g.resourceContent = &resourceContent{} - if g.publishOnce != nil { - g.publishOnce = &publishOnce{logger: g.publishOnce.logger} + return &imageResource{ + Image: image, + baseResource: spec, } - - return &Image{ - imaging: i.imaging, - format: i.format, - genericResource: &g} } -func (i *Image) setBasePath(conf imageConfig) { - i.relTargetDirFile = i.relTargetPathFromConfig(conf) +func (i *imageResource) setBasePath(conf images.ImageConfig) { + i.getResourcePaths().relTargetDirFile = i.relTargetPathFromConfig(conf) } -func (i *Image) relTargetPathFromConfig(conf imageConfig) dirFile { - p1, p2 := helpers.FileAndExt(i.relTargetDirFile.file) +func (i *imageResource) relTargetPathFromConfig(conf images.ImageConfig) dirFile { + p1, p2 := helpers.FileAndExt(i.getResourcePaths().relTargetDirFile.file) + if conf.Action == "trace" { + p2 = ".svg" + } - idStr := fmt.Sprintf("_hu%s_%d", i.hash, i.osFileInfo.Size()) + h, _ := i.hash() + idStr := fmt.Sprintf("_hu%s_%d", h, i.size()) // Do not change for no good reason. const md5Threshold = 100 - key := conf.key(i.format) + key := conf.Key(i.Format) // It is useful to have the key in clear text, but when nesting transforms, it // can easily be too long to read, and maybe even too long @@ -554,43 +244,7 @@ func (i *Image) relTargetPathFromConfig(conf imageConfig) dirFile { } return dirFile{ - dir: i.relTargetDirFile.dir, + dir: i.getResourcePaths().relTargetDirFile.dir, file: fmt.Sprintf("%s%s_%s%s", p1, idStr, key, p2), } - -} - -func decodeImaging(m map[string]interface{}) (Imaging, error) { - var i Imaging - if err := mapstructure.WeakDecode(m, &i); err != nil { - return i, err - } - - if i.Quality == 0 { - i.Quality = defaultJPEGQuality - } else if i.Quality < 0 || i.Quality > 100 { - return i, errors.New("JPEG quality must be a number between 1 and 100") - } - - if i.Anchor == "" || strings.EqualFold(i.Anchor, smartCropIdentifier) { - i.Anchor = smartCropIdentifier - } else { - i.Anchor = strings.ToLower(i.Anchor) - if _, found := anchorPositions[i.Anchor]; !found { - return i, errors.New("invalid anchor value in imaging config") - } - } - - if i.ResampleFilter == "" { - i.ResampleFilter = defaultResampleFilter - } else { - filter := strings.ToLower(i.ResampleFilter) - _, found := imageFilters[filter] - if !found { - return i, fmt.Errorf("%q is not a valid resample filter", filter) - } - i.ResampleFilter = filter - } - - return i, nil } diff --git a/resources/image_cache.go b/resources/image_cache.go index 3324e442e..3a9e3c2c5 100644 --- a/resources/image_cache.go +++ b/resources/image_cache.go @@ -20,7 +20,7 @@ import ( "strings" "sync" - "github.com/gohugoio/hugo/common/hugio" + "github.com/gohugoio/hugo/resources/images" "github.com/gohugoio/hugo/cache/filecache" "github.com/gohugoio/hugo/helpers" @@ -32,7 +32,7 @@ type imageCache struct { fileCache *filecache.Cache mu sync.RWMutex - store map[string]*Image + store map[string]*resourceAdapter } func (c *imageCache) isInCache(key string) bool { @@ -66,33 +66,34 @@ func (c *imageCache) normalizeKey(key string) string { func (c *imageCache) clear() { c.mu.Lock() defer c.mu.Unlock() - c.store = make(map[string]*Image) + c.store = make(map[string]*resourceAdapter) } func (c *imageCache) getOrCreate( - parent *Image, conf imageConfig, createImage func() (*Image, image.Image, error)) (*Image, error) { - + parent *imageResource, conf images.ImageConfig, + createImage func() (*imageResource, image.Image, error)) (*resourceAdapter, error) { relTarget := parent.relTargetPathFromConfig(conf) key := parent.relTargetPathForRel(relTarget.path(), false, false, false) // First check the in-memory store, then the disk. c.mu.RLock() - img, found := c.store[key] + cachedImage, found := c.store[key] c.mu.RUnlock() if found { - return img, nil + return cachedImage, nil } + var img *imageResource + // These funcs are protected by a named lock. // read clones the parent to its new name and copies // the content to the destinations. read := func(info filecache.ItemInfo, r io.Reader) error { - img = parent.clone() - img.relTargetDirFile.file = relTarget.file - img.sourceFilename = info.Name - // Make sure it's always loaded by sourceFilename. - img.openReadSeekerCloser = nil + img = parent.clone(nil) + rp := img.getResourcePaths() + rp.relTargetDirFile.file = relTarget.file + img.setSourceFilename(info.Name) w, err := img.openDestinationsForWriting() if err != nil { @@ -109,29 +110,20 @@ func (c *imageCache) getOrCreate( return err } - // create creates the image and encodes it to w (cache) and to its destinations. + // create creates the image and encodes it to the cache (w). create := func(info filecache.ItemInfo, w io.WriteCloser) (err error) { + defer w.Close() + var conv image.Image img, conv, err = createImage() if err != nil { - w.Close() return } - img.relTargetDirFile.file = relTarget.file - img.sourceFilename = info.Name + rp := img.getResourcePaths() + rp.relTargetDirFile.file = relTarget.file + img.setSourceFilename(info.Name) - destinations, err := img.openDestinationsForWriting() - if err != nil { - w.Close() - return err - } - - if destinations != nil { - w = hugio.NewMultiWriteCloser(w, destinations) - } - defer w.Close() - - return img.encodeTo(conf, conv, w) + return img.EncodeTo(conf, conv, w) } // Now look in the file cache. @@ -147,20 +139,21 @@ func (c *imageCache) getOrCreate( } // The file is now stored in this cache. - img.sourceFs = c.fileCache.Fs + img.setSourceFs(c.fileCache.Fs) c.mu.Lock() - if img2, found := c.store[key]; found { + if cachedImage, found = c.store[key]; found { c.mu.Unlock() - return img2, nil + return cachedImage, nil } - c.store[key] = img - c.mu.Unlock() - return img, nil |