hugo/resources/images/image.go
Bjørn Erik Pedersen 6a246d1152 Add images.Process filter
This allows for constructs like:

```
{{ $filters := slice (images.GaussianBlur 8) (images.Grayscale) (images.Process "jpg q30 resize 200x") }}
{{ $img = $img | images.Filter $filters }}
```

Note that the `action` option in `images.Process` is optional (`resize` in the example above), so you can use the above to just set the target format, e.g.:

```
{{ $filters := slice (images.GaussianBlur 8) (images.Grayscale) (images.Process "jpg") }}
{{ $img = $img | images.Filter $filters }}
```

Fixes #8439
2023-09-24 11:54:29 +02:00

455 lines
11 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 images
import (
"errors"
"fmt"
"image"
"image/color"
"image/draw"
"image/gif"
"image/jpeg"
"image/png"
"io"
"sync"
"github.com/bep/gowebp/libwebp/webpoptions"
"github.com/gohugoio/hugo/config"
"github.com/gohugoio/hugo/resources/images/webp"
"github.com/gohugoio/hugo/media"
"github.com/gohugoio/hugo/resources/images/exif"
"github.com/disintegration/gift"
"golang.org/x/image/bmp"
"golang.org/x/image/tiff"
"github.com/gohugoio/hugo/common/hugio"
)
func NewImage(f Format, proc *ImageProcessor, img image.Image, s Spec) *Image {
if img != nil {
return &Image{
Format: f,
Proc: proc,
Spec: s,
imageConfig: &imageConfig{
config: imageConfigFromImage(img),
configLoaded: true,
},
}
}
return &Image{Format: f, Proc: proc, Spec: s, imageConfig: &imageConfig{}}
}
type Image struct {
Format Format
Proc *ImageProcessor
Spec Spec
*imageConfig
}
func (i *Image) EncodeTo(conf ImageConfig, img image.Image, w io.Writer) error {
switch conf.TargetFormat {
case 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})
case PNG:
encoder := png.Encoder{CompressionLevel: png.DefaultCompression}
return encoder.Encode(w, img)
case GIF:
if giphy, ok := img.(Giphy); ok {
g := giphy.GIF()
return gif.EncodeAll(w, g)
}
return gif.Encode(w, img, &gif.Options{
NumColors: 256,
})
case TIFF:
return tiff.Encode(w, img, &tiff.Options{Compression: tiff.Deflate, Predictor: true})
case BMP:
return bmp.Encode(w, img)
case WEBP:
return webp.Encode(
w,
img, webpoptions.EncodingOptions{
Quality: conf.Quality,
EncodingPreset: webpoptions.EncodingPreset(conf.Hint),
UseSharpYuv: true,
},
)
default:
return errors.New("format not supported")
}
}
// Height returns i's height.
func (i *Image) Height() int {
i.initConfig()
return i.config.Height
}
// Width returns i's width.
func (i *Image) Width() int {
i.initConfig()
return i.config.Width
}
func (i Image) WithImage(img image.Image) *Image {
i.Spec = nil
i.imageConfig = &imageConfig{
config: imageConfigFromImage(img),
configLoaded: true,
}
return &i
}
func (i Image) WithSpec(s Spec) *Image {
i.Spec = s
i.imageConfig = &imageConfig{}
return &i
}
// InitConfig reads the image config from the given reader.
func (i *Image) InitConfig(r io.Reader) error {
var err error
i.configInit.Do(func() {
i.config, _, err = image.DecodeConfig(r)
})
return err
}
func (i *Image) initConfig() error {
var err error
i.configInit.Do(func() {
if i.configLoaded {
return
}
var f hugio.ReadSeekCloser
f, err = i.Spec.ReadSeekCloser()
if err != nil {
return
}
defer f.Close()
i.config, _, err = image.DecodeConfig(f)
})
if err != nil {
return fmt.Errorf("failed to load image config: %w", err)
}
return nil
}
func NewImageProcessor(cfg *config.ConfigNamespace[ImagingConfig, ImagingConfigInternal]) (*ImageProcessor, error) {
e := cfg.Config.Imaging.Exif
exifDecoder, err := exif.NewDecoder(
exif.WithDateDisabled(e.DisableDate),
exif.WithLatLongDisabled(e.DisableLatLong),
exif.ExcludeFields(e.ExcludeFields),
exif.IncludeFields(e.IncludeFields),
)
if err != nil {
return nil, err
}
return &ImageProcessor{
Cfg: cfg,
exifDecoder: exifDecoder,
}, nil
}
type ImageProcessor struct {
Cfg *config.ConfigNamespace[ImagingConfig, ImagingConfigInternal]
exifDecoder *exif.Decoder
}
func (p *ImageProcessor) DecodeExif(r io.Reader) (*exif.ExifInfo, error) {
return p.exifDecoder.Decode(r)
}
func (p *ImageProcessor) FiltersFromConfig(src image.Image, conf ImageConfig) ([]gift.Filter, error) {
var filters []gift.Filter
if conf.Rotate != 0 {
// Apply any rotation before any resize.
filters = append(filters, gift.Rotate(float32(conf.Rotate), color.Transparent, gift.NearestNeighborInterpolation))
}
switch conf.Action {
case "resize":
filters = append(filters, gift.Resize(conf.Width, conf.Height, conf.Filter))
case "crop":
if conf.AnchorStr == smartCropIdentifier {
bounds, err := p.smartCrop(src, conf.Width, conf.Height, conf.Filter)
if err != nil {
return nil, err
}
// First crop using the bounds returned by smartCrop.
filters = append(filters, gift.Crop(bounds))
// Then center crop the image to get an image the desired size without resizing.
filters = append(filters, gift.CropToSize(conf.Width, conf.Height, gift.CenterAnchor))
} else {
filters = append(filters, gift.CropToSize(conf.Width, conf.Height, conf.Anchor))
}
case "fill":
if conf.AnchorStr == smartCropIdentifier {
bounds, err := p.smartCrop(src, conf.Width, conf.Height, conf.Filter)
if err != nil {
return nil, err
}
// First crop it, then resize it.
filters = append(filters, gift.Crop(bounds))
filters = append(filters, gift.Resize(conf.Width, conf.Height, conf.Filter))
} else {
filters = append(filters, gift.ResizeToFill(conf.Width, conf.Height, conf.Filter, conf.Anchor))
}
case "fit":
filters = append(filters, gift.ResizeToFit(conf.Width, conf.Height, conf.Filter))
default:
}
return filters, nil
}
func (p *ImageProcessor) ApplyFiltersFromConfig(src image.Image, conf ImageConfig) (image.Image, error) {
filters, err := p.FiltersFromConfig(src, conf)
if err != nil {
return nil, err
}
if len(filters) == 0 {
return p.resolveSrc(src, conf.TargetFormat), nil
}
img, err := p.doFilter(src, conf.TargetFormat, filters...)
if err != nil {
return nil, err
}
return img, nil
}
func (p *ImageProcessor) Filter(src image.Image, filters ...gift.Filter) (image.Image, error) {
return p.doFilter(src, 0, filters...)
}
func (p *ImageProcessor) resolveSrc(src image.Image, targetFormat Format) image.Image {
if giph, ok := src.(Giphy); ok {
g := giph.GIF()
if len(g.Image) < 2 || (targetFormat == 0 || targetFormat != GIF) {
src = g.Image[0]
}
}
return src
}
func (p *ImageProcessor) doFilter(src image.Image, targetFormat Format, filters ...gift.Filter) (image.Image, error) {
filter := gift.New(filters...)
if giph, ok := src.(Giphy); ok {
g := giph.GIF()
if len(g.Image) < 2 || (targetFormat == 0 || targetFormat != GIF) {
src = g.Image[0]
} else {
var bounds image.Rectangle
firstFrame := g.Image[0]
tmp := image.NewNRGBA(firstFrame.Bounds())
for i := range g.Image {
gift.New().DrawAt(tmp, g.Image[i], g.Image[i].Bounds().Min, gift.OverOperator)
bounds = filter.Bounds(tmp.Bounds())
dst := image.NewPaletted(bounds, g.Image[i].Palette)
filter.Draw(dst, tmp)
g.Image[i] = dst
}
g.Config.Width = bounds.Dx()
g.Config.Height = bounds.Dy()
return giph, nil
}
}
bounds := filter.Bounds(src.Bounds())
var dst draw.Image
switch src.(type) {
case *image.RGBA:
dst = image.NewRGBA(bounds)
case *image.NRGBA:
dst = image.NewNRGBA(bounds)
case *image.Gray:
dst = image.NewGray(bounds)
default:
dst = image.NewNRGBA(bounds)
}
filter.Draw(dst, src)
return dst, nil
}
func GetDefaultImageConfig(action string, defaults *config.ConfigNamespace[ImagingConfig, ImagingConfigInternal]) ImageConfig {
if defaults == nil {
defaults = defaultImageConfig
}
return ImageConfig{
Action: action,
Hint: defaults.Config.Hint,
Quality: defaults.Config.Imaging.Quality,
}
}
type Spec interface {
// Loads the image source.
ReadSeekCloser() (hugio.ReadSeekCloser, error)
}
// Format is an image file format.
type Format int
const (
JPEG Format = iota + 1
PNG
GIF
TIFF
BMP
WEBP
)
// RequiresDefaultQuality returns if the default quality needs to be applied to
// images of this format.
func (f Format) RequiresDefaultQuality() bool {
return f == JPEG || f == WEBP
}
// SupportsTransparency reports whether it supports transparency in any form.
func (f Format) SupportsTransparency() bool {
return f != JPEG
}
// DefaultExtension returns the default file extension of this format, starting with a dot.
// For example: .jpg for JPEG
func (f Format) DefaultExtension() string {
return f.MediaType().FirstSuffix.FullSuffix
}
// MediaType returns the media type of this image, e.g. image/jpeg for JPEG
func (f Format) MediaType() media.Type {
switch f {
case JPEG:
return media.Builtin.JPEGType
case PNG:
return media.Builtin.PNGType
case GIF:
return media.Builtin.GIFType
case TIFF:
return media.Builtin.TIFFType
case BMP:
return media.Builtin.BMPType
case WEBP:
return media.Builtin.WEBPType
default:
panic(fmt.Sprintf("%d is not a valid image format", f))
}
}
type imageConfig struct {
config image.Config
configInit sync.Once
configLoaded bool
}
func imageConfigFromImage(img image.Image) image.Config {
if giphy, ok := img.(Giphy); ok {
return giphy.GIF().Config
}
b := img.Bounds()
return image.Config{Width: b.Max.X, Height: b.Max.Y}
}
// UnwrapFilter unwraps the given filter if it is a filter wrapper.
func UnwrapFilter(in gift.Filter) gift.Filter {
if f, ok := in.(filter); ok {
return f.Filter
}
return in
}
// ToFilters converts the given input to a slice of gift.Filter.
func ToFilters(in any) []gift.Filter {
switch v := in.(type) {
case []gift.Filter:
return v
case []filter:
vv := make([]gift.Filter, len(v))
for i, f := range v {
vv[i] = f
}
return vv
case gift.Filter:
return []gift.Filter{v}
default:
panic(fmt.Sprintf("%T is not an image filter", in))
}
}
// IsOpaque returns false if the image has alpha channel and there is at least 1
// pixel that is not (fully) opaque.
func IsOpaque(img image.Image) bool {
if oim, ok := img.(interface {
Opaque() bool
}); ok {
return oim.Opaque()
}
return false
}
// ImageSource identifies and decodes an image.
type ImageSource interface {
DecodeImage() (image.Image, error)
Key() string
}
// Giphy represents a GIF Image that may be animated.
type Giphy interface {
image.Image // The first frame.
GIF() *gif.GIF // All frames.
}