mirror of
https://github.com/gohugoio/hugo.git
synced 2026-08-24 07:18:54 +00:00
49bfb1070b
* Add AVIF, HEIF and HEIC partial support * Add them as media types. * Support reading metadata (Width, Height, Exif, etc.) from these formats. * Add a new template function IsImageResourceMeta to check if a resource supports image metadata operations, which will return true for AVIF, HEIF and HEIC resources even if they don't support full image operations yet. Fixes #14549
385 lines
9.6 KiB
Go
385 lines
9.6 KiB
Go
// Copyright 2025 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 (
|
|
"bufio"
|
|
"errors"
|
|
"fmt"
|
|
"image"
|
|
"image/color"
|
|
"image/color/palette"
|
|
"image/draw"
|
|
"image/gif"
|
|
"image/jpeg"
|
|
"image/png"
|
|
"io"
|
|
"strings"
|
|
|
|
"github.com/bep/imagemeta"
|
|
"github.com/bep/logg"
|
|
"github.com/gohugoio/hugo/common/himage"
|
|
"github.com/gohugoio/hugo/common/hugio"
|
|
"golang.org/x/image/bmp"
|
|
"golang.org/x/image/tiff"
|
|
)
|
|
|
|
// Decoder defines the decoding of an image format.
|
|
// These matches the globalmpackage image functions.
|
|
type Decoder interface {
|
|
Decode(r io.Reader) (image.Image, error)
|
|
DecodeConfig(r io.Reader) (image.Config, error)
|
|
}
|
|
|
|
type ToEncoder interface {
|
|
EncodeTo(conf ImageConfig, w io.Writer, src image.Image) error
|
|
}
|
|
|
|
// EncoderWithOptions defines the encoding of an image format with the given options.
|
|
type Encoder interface {
|
|
Encode(w io.Writer, src image.Image, options map[string]any) error
|
|
}
|
|
|
|
// EncodeDecoder defines both decoding and encoding of an image format as defined by the standard library.
|
|
type EncodeDecoder interface {
|
|
Decoder
|
|
Encoder
|
|
}
|
|
|
|
// Codec is a generic image codec supporting multiple formats.
|
|
type Codec struct {
|
|
webp EncodeDecoder
|
|
debugl logg.LevelLogger
|
|
}
|
|
|
|
func newCodec(webp EncodeDecoder, debugl logg.LevelLogger) *Codec {
|
|
return &Codec{webp: webp, debugl: debugl}
|
|
}
|
|
|
|
func (d *Codec) EncodeTo(conf ImageConfig, w io.Writer, img image.Image) error {
|
|
d.debugl.Log(
|
|
// This construct looks odd, but the func will only be called if debug is enabled.
|
|
logg.StringFunc(
|
|
func() string {
|
|
return fmt.Sprintf("Encoding image to format %s with config %+v", conf.TargetFormat, conf)
|
|
},
|
|
),
|
|
)
|
|
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 anim, ok := img.(himage.AnimatedImage); ok {
|
|
if g, ok := anim.GetRaw().(*gif.GIF); ok {
|
|
return gif.EncodeAll(w, g)
|
|
}
|
|
|
|
// Animated image, but not a GIF. Convert it.
|
|
frames := anim.GetFrames()
|
|
if len(frames) == 0 {
|
|
return gif.Encode(w, img, &gif.Options{NumColors: 256})
|
|
}
|
|
|
|
frameDurations := anim.GetFrameDurations()
|
|
if len(frameDurations) != len(frames) {
|
|
return errors.New("gif: number of frame durations does not match number of frames")
|
|
}
|
|
|
|
outGif := &gif.GIF{
|
|
Delay: himage.FrameDurationsToGifDelays(frameDurations),
|
|
}
|
|
|
|
outGif.LoopCount = anim.GetLoopCount()
|
|
|
|
for _, frame := range frames {
|
|
bounds := frame.Bounds()
|
|
palettedImage := image.NewPaletted(bounds, palette.Plan9)
|
|
draw.Draw(palettedImage, palettedImage.Rect, frame, bounds.Min, draw.Src)
|
|
outGif.Image = append(outGif.Image, palettedImage)
|
|
}
|
|
|
|
return gif.EncodeAll(w, outGif)
|
|
}
|
|
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:
|
|
// Convert bool to int because the C code reads it as a number.
|
|
useSharpYuvInt := 0
|
|
if conf.UseSharpYuv {
|
|
useSharpYuvInt = 1
|
|
}
|
|
opts := map[string]any{
|
|
"compression": conf.Compression,
|
|
"quality": conf.Quality,
|
|
"hint": conf.Hint,
|
|
"useSharpYuv": useSharpYuvInt,
|
|
"method": conf.Method,
|
|
}
|
|
return d.webp.Encode(w, img, opts)
|
|
default:
|
|
return errors.New("format not supported")
|
|
}
|
|
}
|
|
|
|
func (d *Codec) DecodeFormat(f Format, r io.Reader) (image.Image, error) {
|
|
d.debugl.Log(
|
|
logg.StringFunc(
|
|
func() string {
|
|
return fmt.Sprintf("Decoding image from format %s", f)
|
|
},
|
|
),
|
|
)
|
|
switch f {
|
|
case JPEG, PNG:
|
|
// We reworked this decode/encode setup to get full WebP support in v0.153.0.
|
|
// In the first take of that we used f to decide whether to call png.Decode or jpeg.Decode here,
|
|
// but testing it on some sites, it seems that it's not uncommon to store JPEGs with PNG extensions and vice versa.
|
|
// So, to reduce some noise in that release, we fallback to the standard library here,
|
|
// which will read the magic bytes and decode accordingly.
|
|
img, _, err := image.Decode(r)
|
|
return img, err
|
|
case GIF:
|
|
g, err := gif.DecodeAll(r)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to decode gif: %w", err)
|
|
}
|
|
if len(g.Delay) > 1 {
|
|
return &giphy{gif: g, Image: g.Image[0]}, nil
|
|
}
|
|
return g.Image[0], nil
|
|
case TIFF:
|
|
return tiff.Decode(r)
|
|
case BMP:
|
|
return bmp.Decode(r)
|
|
case WEBP:
|
|
img, err := d.webp.Decode(r)
|
|
if err == nil {
|
|
return img, nil
|
|
}
|
|
if rs, ok := r.(io.ReadSeeker); ok {
|
|
// See issue 14288. Turns out it's not uncommon to e.g. name their PNG files with a WEBP extension.
|
|
// With the old Go's webp decoder, this didn't fail (it looked for the file header),
|
|
// but now some error has surfaced.
|
|
// To reduce some noise, we try to reset and decode again using the standard library.
|
|
_, err2 := rs.Seek(0, io.SeekStart)
|
|
if err2 != nil {
|
|
return nil, err
|
|
}
|
|
img, _, err2 = image.Decode(rs)
|
|
if err2 == nil {
|
|
return img, nil
|
|
}
|
|
}
|
|
return nil, err
|
|
default:
|
|
return nil, errors.New("format not supported")
|
|
}
|
|
}
|
|
|
|
func (d *Codec) Decode(r io.Reader) (image.Image, error) {
|
|
d.debugl.Log(
|
|
logg.StringFunc(
|
|
func() string {
|
|
return "Decoding image from unknown format"
|
|
},
|
|
),
|
|
)
|
|
rr := toPeekReader(r)
|
|
format, err := formatFromImage(rr)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if format != 0 {
|
|
return d.DecodeFormat(format, rr)
|
|
}
|
|
|
|
// Fallback to the standard image.Decode.
|
|
img, _, err := image.Decode(rr)
|
|
return img, err
|
|
}
|
|
|
|
func (d *Codec) DecodeConfig(f Format, r io.Reader) (image.Config, string, error) {
|
|
rr := toPeekReader(r)
|
|
format, err := formatFromImage(rr)
|
|
if err != nil {
|
|
return image.Config{}, "", err
|
|
}
|
|
if format == 0 {
|
|
format = f
|
|
}
|
|
r = rr
|
|
if format.UseImageMetaConfigDecoder() {
|
|
rs, err := hugio.NewReadSeekerNoOpCloserFromReader(r)
|
|
if err != nil {
|
|
return image.Config{}, "", err
|
|
}
|
|
rs.Seek(0, 0)
|
|
res, err := imagemeta.Decode(
|
|
imagemeta.Options{
|
|
R: rs,
|
|
ImageFormat: format.ToImageMetaImageFormatFormat(),
|
|
Sources: imagemeta.CONFIG,
|
|
},
|
|
)
|
|
if err == nil {
|
|
return image.Config{
|
|
Width: res.ImageConfig.Width,
|
|
Height: res.ImageConfig.Height,
|
|
ColorModel: color.RGBAModel,
|
|
}, strings.ToLower(format.String()), nil
|
|
}
|
|
|
|
// Fallback to the standard image.DecodeConfig.
|
|
rs.Seek(0, 0)
|
|
r = rs
|
|
}
|
|
|
|
conf, name, err := image.DecodeConfig(r)
|
|
|
|
return conf, name, err
|
|
}
|
|
|
|
// toPeekReader converts an io.Reader to a peekReader.
|
|
func toPeekReader(r io.Reader) peekReader {
|
|
if rr, ok := r.(peekReader); ok {
|
|
return rr
|
|
}
|
|
return bufio.NewReader(r)
|
|
}
|
|
|
|
// A peekReader is an io.Reader that can also peek ahead.
|
|
type peekReader interface {
|
|
io.Reader
|
|
Peek(int) ([]byte, error)
|
|
}
|
|
|
|
const (
|
|
// The WebP file header is 12 bytes long and starts with "RIFF" followed by
|
|
// 4 bytes indicating the file size, followed by "WEBP" and the VP8 chunk header.
|
|
// We use '?' as a wildcard for the 4 size bytes.
|
|
magicWebp = "RIFF????WEBPVP8"
|
|
// The GIF file header is 6 bytes long and starts with "GIF87a" or "GIF89a".
|
|
magicGif = "GIF8???"
|
|
)
|
|
|
|
type magicFormat struct {
|
|
magic string
|
|
format Format
|
|
}
|
|
|
|
var magicFormats = []magicFormat{
|
|
{magic: magicWebp, format: WEBP},
|
|
{magic: magicGif, format: GIF},
|
|
}
|
|
|
|
// formatFromImage determines the image format from the magic bytes.
|
|
// Note that this is only a partial implementation,
|
|
// as we currently only need WebP and GIF detection.
|
|
// The others can be handled by the standard library.
|
|
func formatFromImage(r peekReader) (Format, error) {
|
|
for _, mf := range magicFormats {
|
|
magicLen := len(mf.magic)
|
|
b, err := r.Peek(magicLen)
|
|
if err == nil && match(mf.magic, b) {
|
|
return mf.format, nil
|
|
}
|
|
}
|
|
return 0, nil
|
|
}
|
|
|
|
func match(magic string, b []byte) bool {
|
|
if len(magic) != len(b) {
|
|
return false
|
|
}
|
|
for i := 0; i < len(magic); i++ {
|
|
if magic[i] != '?' && magic[i] != b[i] {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
var (
|
|
_ himage.AnimatedImage = (*giphy)(nil)
|
|
_ himage.ImageConfigProvider = (*giphy)(nil)
|
|
)
|
|
|
|
type giphy struct {
|
|
image.Image
|
|
gif *gif.GIF
|
|
}
|
|
|
|
func (g *giphy) GetRaw() any {
|
|
return g.gif
|
|
}
|
|
|
|
func (g *giphy) GetLoopCount() int {
|
|
return g.gif.LoopCount
|
|
}
|
|
|
|
func (g *giphy) GetFrames() []image.Image {
|
|
frames := make([]image.Image, len(g.gif.Image))
|
|
for i, frame := range g.gif.Image {
|
|
frames[i] = frame
|
|
}
|
|
return frames
|
|
}
|
|
|
|
func (g *giphy) GetImageConfig() image.Config {
|
|
return g.gif.Config
|
|
}
|
|
|
|
func (g *giphy) SetFrames(frames []image.Image) {
|
|
if len(frames) == 0 {
|
|
panic("frames cannot be empty")
|
|
}
|
|
g.gif.Image = make([]*image.Paletted, len(frames))
|
|
for i, frame := range frames {
|
|
g.gif.Image[i] = frame.(*image.Paletted)
|
|
}
|
|
g.Image = g.gif.Image[0]
|
|
}
|
|
|
|
func (g *giphy) SetWidthHeight(width, height int) {
|
|
g.gif.Config.Width = width
|
|
g.gif.Config.Height = height
|
|
}
|
|
|
|
func (g *giphy) GetFrameDurations() []int {
|
|
return himage.GifDelaysToFrameDurations(g.gif.Delay)
|
|
}
|