mirror of
https://github.com/gohugoio/hugo.git
synced 2024-11-07 20:30:36 -05:00
6a246d1152
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
455 lines
11 KiB
Go
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.
|
|
}
|