diff options
author | Bjørn Erik Pedersen <[email protected]> | 2019-01-02 11:58:32 +0100 |
---|---|---|
committer | Bjørn Erik Pedersen <[email protected]> | 2019-01-02 14:25:37 +0100 |
commit | ce8a09a4c0661dece931ab1173e4f09e8e04aa38 (patch) | |
tree | 870372211e342312d0ab3034ee03285c6f698f9d /resources/image.go | |
parent | 669ada436787311cc5d02dae5b88e60a09adda58 (diff) | |
download | hugo-ce8a09a4c0661dece931ab1173e4f09e8e04aa38.tar.gz hugo-ce8a09a4c0661dece931ab1173e4f09e8e04aa38.zip |
resources: Move resource interfaces into its own package
Diffstat (limited to 'resources/image.go')
-rw-r--r-- | resources/image.go | 603 |
1 files changed, 603 insertions, 0 deletions
diff --git a/resources/image.go b/resources/image.go new file mode 100644 index 000000000..d46facac5 --- /dev/null +++ b/resources/image.go @@ -0,0 +1,603 @@ +// 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 resources + +import ( + "errors" + "fmt" + "image" + "image/color" + "image/draw" + "image/jpeg" + "io" + "io/ioutil" + "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" + + // 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) +) + +// 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 + + // The anchor used in Fill. Default is "smart", i.e. Smart Crop. + Anchor string +} + +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, + } + + // 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 + + copyToDestinationInit sync.Once + + imaging *Imaging + + format imaging.Format + + *genericResource +} + +// Width returns i's width. +func (i *Image) Width() int { + i.initConfig() + return i.config.Width +} + +// Height returns i's height. +func (i *Image) Height() int { + i.initConfig() + return i.config.Height +} + +// 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)} +} + +// 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 + }) +} + +// 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 + }) +} + +// 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 + }) +} + +// 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) + return strings.HasSuffix(name, ".jpg") || strings.HasSuffix(name, ".jpeg") +} + +// Serialize image processing. The imaging library spins up its own set of Go routines, +// so there is not much to gain from adding more load to the mix. That +// can even have negative effect in low resource scenarios. +// Note that this only effects the non-cached scenario. Once the processed +// image is written to disk, everything is fast, fast fast. +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) + 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) { + imageProcSem <- true + defer func() { + <-imageProcSem + }() + + ci := i.clone() + + errOp := action + errPath := i.sourceFilename + + ci.setBasePath(conf) + + src, err := i.decodeSource() + if err != nil { + return nil, nil, &os.PathError{Op: errOp, Path: errPath, Err: err} + } + + if conf.Rotate != 0 { + // Rotate it before any scaling to get the dimensions correct. + src = imaging.Rotate(src, float64(conf.Rotate), color.Transparent) + } + + converted, err := f(src, conf) + if err != nil { + return ci, nil, &os.PathError{Op: errOp, Path: errPath, Err: err} + } + + 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) + draw.FloydSteinberg.Draw(tmp, tmp.Bounds(), converted, converted.Bounds().Min) + converted = tmp + } + } + + b := converted.Bounds() + ci.config = image.Config{Width: b.Max.X, Height: b.Max.Y} + ci.configLoaded = true + + 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") + } + + 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 + }) + + if err != nil { + return _errors.Wrap(err, "failed to load image config") + } + + return nil +} + +func (i *Image) decodeSource() (image.Image, error) { + f, err := i.ReadSeekCloser() + if err != nil { + return nil, _errors.Wrap(err, "failed to open image for decode") + } + defer f.Close() + img, _, err := image.Decode(f) + return img, err +} + +func (i *Image) openDestinationsForWriting() (io.WriteCloser, error) { + targetFilenames := i.targetFilenames() + var changedFilenames []string + + // Fast path: + // This is a processed version of the original. + // If it exists on destination with the same filename and file size, it is + // the same file, so no need to transfer it again. + for _, targetFilename := range targetFilenames { + if fi, err := i.spec.BaseFs.PublishFs.Stat(targetFilename); err == nil && fi.Size() == i.osFileInfo.Size() { + continue + } + changedFilenames = append(changedFilenames, targetFilename) + } + + if len(changedFilenames) == 0 { + return struct { + io.Writer + io.Closer + }{ + ioutil.Discard, + ioutil.NopCloser(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 + + 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) + } +} + +func (i *Image) clone() *Image { + g := *i.genericResource + g.resourceContent = &resourceContent{} + + return &Image{ + imaging: i.imaging, + format: i.format, + genericResource: &g} +} + +func (i *Image) setBasePath(conf imageConfig) { + i.relTargetDirFile = i.relTargetPathFromConfig(conf) +} + +func (i *Image) relTargetPathFromConfig(conf imageConfig) dirFile { + p1, p2 := helpers.FileAndExt(i.relTargetDirFile.file) + + idStr := fmt.Sprintf("_hu%s_%d", i.hash, i.osFileInfo.Size()) + + // Do not change for no good reason. + const md5Threshold = 100 + + 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 + // for the different OSes to handle. + if len(p1)+len(idStr)+len(p2) > md5Threshold { + key = helpers.MD5String(p1 + key + p2) + huIdx := strings.Index(p1, "_hu") + if huIdx != -1 { + p1 = p1[:huIdx] + } else { + // This started out as a very long file name. Making it even longer + // could melt ice in the Arctic. + p1 = "" + } + } else if strings.Contains(p1, idStr) { + // On scaling an already scaled image, we get the file info from the original. + // Repeating the same info in the filename makes it stuttery for no good reason. + idStr = "" + } + + return dirFile{ + dir: i.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 +} |