mirror of
https://github.com/gohugoio/hugo.git
synced 2024-11-28 22:42:13 -05:00
e197c7b29d
To sort an image's colors from darkest to lightest, you can then do: ```handlebars {{ {{ $colorsByLuminance := sort $image.Colors "Luminance" }} ``` This uses the formula defined here: https://www.w3.org/TR/WCAG21/#dfn-relative-luminance Fixes #10450
522 lines
14 KiB
Go
522 lines
14 KiB
Go
// 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 (
|
|
"encoding/json"
|
|
"fmt"
|
|
"image"
|
|
"image/color"
|
|
"image/draw"
|
|
"image/gif"
|
|
_ "image/png"
|
|
"io"
|
|
"os"
|
|
"strings"
|
|
"sync"
|
|
|
|
color_extractor "github.com/marekm4/color-extractor"
|
|
|
|
"github.com/gohugoio/hugo/cache/filecache"
|
|
"github.com/gohugoio/hugo/common/hstrings"
|
|
"github.com/gohugoio/hugo/common/paths"
|
|
"github.com/gohugoio/hugo/identity"
|
|
|
|
"github.com/disintegration/gift"
|
|
|
|
"github.com/gohugoio/hugo/resources/images/exif"
|
|
"github.com/gohugoio/hugo/resources/internal"
|
|
|
|
"github.com/gohugoio/hugo/resources/resource"
|
|
|
|
"github.com/gohugoio/hugo/helpers"
|
|
"github.com/gohugoio/hugo/resources/images"
|
|
|
|
// Blind import for image.Decode
|
|
_ "golang.org/x/image/webp"
|
|
)
|
|
|
|
var (
|
|
_ images.ImageResource = (*imageResource)(nil)
|
|
_ resource.Source = (*imageResource)(nil)
|
|
_ resource.Cloner = (*imageResource)(nil)
|
|
_ resource.NameNormalizedProvider = (*imageResource)(nil)
|
|
)
|
|
|
|
// imageResource represents an image resource.
|
|
type imageResource struct {
|
|
*images.Image
|
|
|
|
// When a image is processed in a chain, this holds the reference to the
|
|
// original (first).
|
|
root *imageResource
|
|
|
|
metaInit sync.Once
|
|
metaInitErr error
|
|
meta *imageMeta
|
|
|
|
dominantColorInit sync.Once
|
|
dominantColors []images.Color
|
|
|
|
baseResource
|
|
}
|
|
|
|
type imageMeta struct {
|
|
Exif *exif.ExifInfo
|
|
}
|
|
|
|
func (i *imageResource) Exif() *exif.ExifInfo {
|
|
return i.root.getExif()
|
|
}
|
|
|
|
func (i *imageResource) getExif() *exif.ExifInfo {
|
|
i.metaInit.Do(func() {
|
|
supportsExif := i.Format == images.JPEG || i.Format == images.TIFF
|
|
if !supportsExif {
|
|
return
|
|
}
|
|
|
|
key := i.getImageMetaCacheTargetPath()
|
|
|
|
read := func(info filecache.ItemInfo, r io.ReadSeeker) error {
|
|
meta := &imageMeta{}
|
|
data, err := io.ReadAll(r)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if err = json.Unmarshal(data, &meta); err != nil {
|
|
return err
|
|
}
|
|
|
|
i.meta = meta
|
|
|
|
return nil
|
|
}
|
|
|
|
create := func(info filecache.ItemInfo, w io.WriteCloser) (err error) {
|
|
defer w.Close()
|
|
f, err := i.root.ReadSeekCloser()
|
|
if err != nil {
|
|
i.metaInitErr = err
|
|
return
|
|
}
|
|
defer f.Close()
|
|
|
|
x, err := i.getSpec().imaging.DecodeExif(f)
|
|
if err != nil {
|
|
i.getSpec().Logger.Warnf("Unable to decode Exif metadata from image: %s", i.Key())
|
|
return nil
|
|
}
|
|
|
|
i.meta = &imageMeta{Exif: x}
|
|
|
|
// Also write it to cache
|
|
enc := json.NewEncoder(w)
|
|
return enc.Encode(i.meta)
|
|
}
|
|
|
|
_, i.metaInitErr = i.getSpec().ImageCache.fcache.ReadOrCreate(key, read, create)
|
|
})
|
|
|
|
if i.metaInitErr != nil {
|
|
panic(fmt.Sprintf("metadata init failed: %s", i.metaInitErr))
|
|
}
|
|
|
|
if i.meta == nil {
|
|
return nil
|
|
}
|
|
|
|
return i.meta.Exif
|
|
}
|
|
|
|
// Colors returns a slice of the most dominant colors in an image
|
|
// using a simple histogram method.
|
|
func (i *imageResource) Colors() ([]images.Color, error) {
|
|
var err error
|
|
i.dominantColorInit.Do(func() {
|
|
var img image.Image
|
|
img, err = i.DecodeImage()
|
|
if err != nil {
|
|
return
|
|
}
|
|
colors := color_extractor.ExtractColors(img)
|
|
for _, c := range colors {
|
|
i.dominantColors = append(i.dominantColors, images.ColorGoToColor(c))
|
|
}
|
|
})
|
|
return i.dominantColors, nil
|
|
}
|
|
|
|
// Clone is for internal use.
|
|
func (i *imageResource) Clone() resource.Resource {
|
|
gr := i.baseResource.Clone().(baseResource)
|
|
return &imageResource{
|
|
root: i.root,
|
|
Image: i.WithSpec(gr),
|
|
baseResource: gr,
|
|
}
|
|
}
|
|
|
|
func (i *imageResource) cloneTo(targetPath string) resource.Resource {
|
|
gr := i.baseResource.cloneTo(targetPath).(baseResource)
|
|
return &imageResource{
|
|
root: i.root,
|
|
Image: i.WithSpec(gr),
|
|
baseResource: gr,
|
|
}
|
|
}
|
|
|
|
func (i *imageResource) cloneWithUpdates(u *transformationUpdate) (baseResource, error) {
|
|
base, err := i.baseResource.cloneWithUpdates(u)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var img *images.Image
|
|
|
|
if u.isContentChanged() {
|
|
img = i.WithSpec(base)
|
|
} else {
|
|
img = i.Image
|
|
}
|
|
|
|
return &imageResource{
|
|
root: i.root,
|
|
Image: img,
|
|
baseResource: base,
|
|
}, nil
|
|
}
|
|
|
|
var imageActions = []string{images.ActionResize, images.ActionCrop, images.ActionFit, images.ActionFill}
|
|
|
|
// Process processes the image with the given spec.
|
|
// The spec can contain an optional action, one of "resize", "crop", "fit" or "fill".
|
|
// This makes this method a more flexible version that covers all of Resize, Crop, Fit and Fill,
|
|
// but it also supports e.g. format conversions without any resize action.
|
|
func (i *imageResource) Process(spec string) (images.ImageResource, error) {
|
|
action, options := i.resolveActionOptions(spec)
|
|
return i.processActionOptions(action, options)
|
|
}
|
|
|
|
// 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 *imageResource) Resize(spec string) (images.ImageResource, error) {
|
|
return i.processActionSpec(images.ActionResize, spec)
|
|
}
|
|
|
|
// Crop the image to the specified dimensions without resizing using the given anchor point.
|
|
// Space delimited config, e.g. `200x300 TopLeft`.
|
|
func (i *imageResource) Crop(spec string) (images.ImageResource, error) {
|
|
return i.processActionSpec(images.ActionCrop, spec)
|
|
}
|
|
|
|
// Fit scales down the image using the specified resample filter to fit the specified
|
|
// maximum width and height.
|
|
func (i *imageResource) Fit(spec string) (images.ImageResource, error) {
|
|
return i.processActionSpec(images.ActionFit, spec)
|
|
}
|
|
|
|
// 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, e.g. `200x300 TopLeft`.
|
|
func (i *imageResource) Fill(spec string) (images.ImageResource, error) {
|
|
return i.processActionSpec(images.ActionFill, spec)
|
|
}
|
|
|
|
func (i *imageResource) Filter(filters ...any) (images.ImageResource, error) {
|
|
var conf images.ImageConfig
|
|
|
|
var gfilters []gift.Filter
|
|
|
|
for _, f := range filters {
|
|
gfilters = append(gfilters, images.ToFilters(f)...)
|
|
}
|
|
|
|
var (
|
|
targetFormat images.Format
|
|
configSet bool
|
|
)
|
|
for _, f := range gfilters {
|
|
f = images.UnwrapFilter(f)
|
|
if specProvider, ok := f.(images.ImageProcessSpecProvider); ok {
|
|
action, options := i.resolveActionOptions(specProvider.ImageProcessSpec())
|
|
var err error
|
|
conf, err = images.DecodeImageConfig(action, options, i.Proc.Cfg, i.Format)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
configSet = true
|
|
if conf.TargetFormat != 0 {
|
|
targetFormat = conf.TargetFormat
|
|
// We only support one target format, but prefer the last one,
|
|
// so we keep going.
|
|
}
|
|
}
|
|
}
|
|
|
|
if !configSet {
|
|
conf = images.GetDefaultImageConfig("filter", i.Proc.Cfg)
|
|
}
|
|
|
|
conf.Action = "filter"
|
|
conf.Key = identity.HashString(gfilters)
|
|
conf.TargetFormat = targetFormat
|
|
if conf.TargetFormat == 0 {
|
|
conf.TargetFormat = i.Format
|
|
}
|
|
|
|
return i.doWithImageConfig(conf, func(src image.Image) (image.Image, error) {
|
|
var filters []gift.Filter
|
|
for _, f := range gfilters {
|
|
f = images.UnwrapFilter(f)
|
|
if specProvider, ok := f.(images.ImageProcessSpecProvider); ok {
|
|
processSpec := specProvider.ImageProcessSpec()
|
|
action, options := i.resolveActionOptions(processSpec)
|
|
conf, err := images.DecodeImageConfig(action, options, i.Proc.Cfg, i.Format)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
pFilters, err := i.Proc.FiltersFromConfig(src, conf)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
filters = append(filters, pFilters...)
|
|
} else if orientationProvider, ok := f.(images.ImageFilterFromOrientationProvider); ok {
|
|
tf := orientationProvider.AutoOrient(i.Exif())
|
|
if tf != nil {
|
|
filters = append(filters, tf)
|
|
}
|
|
} else {
|
|
filters = append(filters, f)
|
|
}
|
|
}
|
|
return i.Proc.Filter(src, filters...)
|
|
})
|
|
}
|
|
|
|
func (i *imageResource) resolveActionOptions(spec string) (string, []string) {
|
|
var action string
|
|
options := strings.Fields(spec)
|
|
for i, p := range options {
|
|
if hstrings.InSlicEqualFold(imageActions, p) {
|
|
action = p
|
|
options = append(options[:i], options[i+1:]...)
|
|
break
|
|
}
|
|
}
|
|
return action, options
|
|
}
|
|
|
|
func (i *imageResource) processActionSpec(action, spec string) (images.ImageResource, error) {
|
|
return i.processActionOptions(action, strings.Fields(spec))
|
|
}
|
|
|
|
func (i *imageResource) processActionOptions(action string, options []string) (images.ImageResource, error) {
|
|
conf, err := images.DecodeImageConfig(action, options, i.Proc.Cfg, i.Format)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
img, err := i.doWithImageConfig(conf, func(src image.Image) (image.Image, error) {
|
|
return i.Proc.ApplyFiltersFromConfig(src, conf)
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if action == images.ActionFill {
|
|
if conf.Anchor == 0 && img.Width() == 0 || img.Height() == 0 {
|
|
// See https://github.com/gohugoio/hugo/issues/7955
|
|
// Smartcrop fails silently in some rare cases.
|
|
// Fall back to a center fill.
|
|
conf.Anchor = gift.CenterAnchor
|
|
conf.AnchorStr = "center"
|
|
return i.doWithImageConfig(conf, func(src image.Image) (image.Image, error) {
|
|
return i.Proc.ApplyFiltersFromConfig(src, conf)
|
|
})
|
|
}
|
|
}
|
|
|
|
return img, nil
|
|
}
|
|
|
|
// 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 *imageResource) doWithImageConfig(conf images.ImageConfig, f func(src image.Image) (image.Image, error)) (images.ImageResource, error) {
|
|
img, err := i.getSpec().ImageCache.getOrCreate(i, conf, func() (*imageResource, image.Image, error) {
|
|
imageProcSem <- true
|
|
defer func() {
|
|
<-imageProcSem
|
|
}()
|
|
|
|
src, err := i.DecodeImage()
|
|
if err != nil {
|
|
return nil, nil, &os.PathError{Op: conf.Action, Path: i.TargetPath(), Err: err}
|
|
}
|
|
|
|
converted, err := f(src)
|
|
if err != nil {
|
|
return nil, nil, &os.PathError{Op: conf.Action, Path: i.TargetPath(), Err: err}
|
|
}
|
|
|
|
hasAlpha := !images.IsOpaque(converted)
|
|
shouldFill := conf.BgColor != nil && hasAlpha
|
|
shouldFill = shouldFill || (!conf.TargetFormat.SupportsTransparency() && hasAlpha)
|
|
var bgColor color.Color
|
|
|
|
if shouldFill {
|
|
bgColor = conf.BgColor
|
|
if bgColor == nil {
|
|
bgColor = i.Proc.Cfg.Config.BgColor
|
|
}
|
|
tmp := image.NewRGBA(converted.Bounds())
|
|
draw.Draw(tmp, tmp.Bounds(), image.NewUniform(bgColor), image.Point{}, draw.Src)
|
|
draw.Draw(tmp, tmp.Bounds(), converted, converted.Bounds().Min, draw.Over)
|
|
converted = tmp
|
|
}
|
|
|
|
if conf.TargetFormat == images.PNG {
|
|
// Apply the colour palette from the source
|
|
if paletted, ok := src.(*image.Paletted); ok {
|
|
palette := paletted.Palette
|
|
if bgColor != nil && len(palette) < 256 {
|
|
palette = images.AddColorToPalette(bgColor, palette)
|
|
} else if bgColor != nil {
|
|
images.ReplaceColorInPalette(bgColor, palette)
|
|
}
|
|
tmp := image.NewPaletted(converted.Bounds(), palette)
|
|
draw.FloydSteinberg.Draw(tmp, tmp.Bounds(), converted, converted.Bounds().Min)
|
|
converted = tmp
|
|
}
|
|
}
|
|
|
|
ci := i.clone(converted)
|
|
targetPath := i.relTargetPathFromConfig(conf)
|
|
ci.setTargetPath(targetPath)
|
|
ci.Format = conf.TargetFormat
|
|
ci.setMediaType(conf.TargetFormat.MediaType())
|
|
|
|
return ci, converted, nil
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return img, nil
|
|
}
|
|
|
|
type giphy struct {
|
|
image.Image
|
|
gif *gif.GIF
|
|
}
|
|
|
|
func (g *giphy) GIF() *gif.GIF {
|
|
return g.gif
|
|
}
|
|
|
|
// DecodeImage decodes the image source into an Image.
|
|
// This for internal use only.
|
|
func (i *imageResource) DecodeImage() (image.Image, error) {
|
|
f, err := i.ReadSeekCloser()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to open image for decode: %w", err)
|
|
}
|
|
defer f.Close()
|
|
|
|
if i.Format == images.GIF {
|
|
g, err := gif.DecodeAll(f)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to decode gif: %w", err)
|
|
}
|
|
return &giphy{gif: g, Image: g.Image[0]}, nil
|
|
}
|
|
img, _, err := image.Decode(f)
|
|
return img, err
|
|
}
|
|
|
|
func (i *imageResource) clone(img image.Image) *imageResource {
|
|
spec := i.baseResource.Clone().(baseResource)
|
|
|
|
var image *images.Image
|
|
if img != nil {
|
|
image = i.WithImage(img)
|
|
} else {
|
|
image = i.WithSpec(spec)
|
|
}
|
|
|
|
return &imageResource{
|
|
Image: image,
|
|
root: i.root,
|
|
baseResource: spec,
|
|
}
|
|
}
|
|
|
|
func (i *imageResource) getImageMetaCacheTargetPath() string {
|
|
const imageMetaVersionNumber = 1 // Increment to invalidate the meta cache
|
|
|
|
cfgHash := i.getSpec().imaging.Cfg.SourceHash
|
|
df := i.getResourcePaths()
|
|
p1, _ := paths.FileAndExt(df.File)
|
|
h := i.hash()
|
|
idStr := identity.HashString(h, i.size(), imageMetaVersionNumber, cfgHash)
|
|
df.File = fmt.Sprintf("%s_%s.json", p1, idStr)
|
|
return df.TargetPath()
|
|
}
|
|
|
|
func (i *imageResource) relTargetPathFromConfig(conf images.ImageConfig) internal.ResourcePaths {
|
|
p1, p2 := paths.FileAndExt(i.getResourcePaths().File)
|
|
if conf.TargetFormat != i.Format {
|
|
p2 = conf.TargetFormat.DefaultExtension()
|
|
}
|
|
|
|
h := i.hash()
|
|
idStr := fmt.Sprintf("_hu%s_%d", h, i.size())
|
|
|
|
// Do not change for no good reason.
|
|
const md5Threshold = 100
|
|
|
|
key := conf.GetKey(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 = ""
|
|
}
|
|
|
|
rp := i.getResourcePaths()
|
|
rp.File = fmt.Sprintf("%s%s_%s%s", p1, idStr, key, p2)
|
|
|
|
return rp
|
|
}
|