Mike Schwörer
73b80a66bc
All checks were successful
Build Docker and Deploy / Run goext test-suite (push) Successful in 1m16s
322 lines
11 KiB
Go
322 lines
11 KiB
Go
package imageext
|
|
|
|
import (
|
|
"bytes"
|
|
"fmt"
|
|
"github.com/disintegration/imaging"
|
|
"gogs.mikescher.com/BlackForestBytes/goext/exerr"
|
|
"gogs.mikescher.com/BlackForestBytes/goext/mathext"
|
|
"image"
|
|
"image/color"
|
|
"image/draw"
|
|
"image/jpeg"
|
|
"image/png"
|
|
"io"
|
|
"math"
|
|
)
|
|
|
|
type ImageFit string //@enum:type
|
|
|
|
const (
|
|
ImageFitStretch ImageFit = "STRETCH"
|
|
ImageFitCover ImageFit = "COVER"
|
|
ImageFitContainCenter ImageFit = "CONTAIN_CENTER"
|
|
ImageFitContainTopLeft ImageFit = "CONTAIN_TOPLEFT"
|
|
ImageFitContainTopRight ImageFit = "CONTAIN_TOPRIGHT"
|
|
ImageFitContainBottomLeft ImageFit = "CONTAIN_BOTTOMLEFT"
|
|
ImageFitContainBottomRight ImageFit = "CONTAIN_BOTTOMRIGHT"
|
|
)
|
|
|
|
type ImageCrop struct { // all crop values are percentages!
|
|
|
|
CropX float64 `bson:"cropX" json:"cropX"`
|
|
CropY float64 `bson:"cropY" json:"cropY"`
|
|
CropWidth float64 `bson:"cropWidth" json:"cropWidth"`
|
|
CropHeight float64 `bson:"cropHeight" json:"cropHeight"`
|
|
}
|
|
|
|
type ImageCompresson string //@enum:type
|
|
|
|
const (
|
|
CompressionPNGNone ImageCompresson = "PNG_NONE"
|
|
CompressionPNGSpeed ImageCompresson = "PNG_SPEED"
|
|
CompressionPNGBest ImageCompresson = "PNG_BEST"
|
|
CompressionJPEG100 ImageCompresson = "JPEG_100"
|
|
CompressionJPEG90 ImageCompresson = "JPEG_090"
|
|
CompressionJPEG80 ImageCompresson = "JPEG_080"
|
|
CompressionJPEG70 ImageCompresson = "JPEG_070"
|
|
CompressionJPEG60 ImageCompresson = "JPEG_060"
|
|
CompressionJPEG50 ImageCompresson = "JPEG_050"
|
|
CompressionJPEG25 ImageCompresson = "JPEG_025"
|
|
CompressionJPEG10 ImageCompresson = "JPEG_010"
|
|
CompressionJPEG1 ImageCompresson = "JPEG_001"
|
|
)
|
|
|
|
func CropImage(img image.Image, px float64, py float64, pw float64, ph float64) (image.Image, error) {
|
|
|
|
type subImager interface {
|
|
SubImage(r image.Rectangle) image.Image
|
|
}
|
|
|
|
x := int(float64(img.Bounds().Dx()) * px)
|
|
y := int(float64(img.Bounds().Dy()) * py)
|
|
w := int(float64(img.Bounds().Dx()) * pw)
|
|
h := int(float64(img.Bounds().Dy()) * ph)
|
|
|
|
if simg, ok := img.(subImager); ok {
|
|
|
|
return simg.SubImage(image.Rect(x, y, x+w, y+h)), nil
|
|
|
|
} else {
|
|
|
|
bfr1 := bytes.Buffer{}
|
|
err := png.Encode(&bfr1, img)
|
|
if err != nil {
|
|
return nil, exerr.Wrap(err, "").Build()
|
|
}
|
|
imgPNG, err := png.Decode(&bfr1)
|
|
if err != nil {
|
|
return nil, exerr.Wrap(err, "").Build()
|
|
}
|
|
|
|
return imgPNG.(subImager).SubImage(image.Rect(x, y, w+w, y+h)), nil
|
|
}
|
|
}
|
|
|
|
func EncodeImage(img image.Image, compression ImageCompresson) (bytes.Buffer, string, error) {
|
|
var err error
|
|
|
|
bfr := bytes.Buffer{}
|
|
|
|
switch compression {
|
|
case CompressionPNGNone:
|
|
enc := &png.Encoder{CompressionLevel: png.NoCompression}
|
|
err = enc.Encode(&bfr, img)
|
|
if err != nil {
|
|
return bytes.Buffer{}, "", exerr.Wrap(err, "").Build()
|
|
}
|
|
return bfr, "image/png", nil
|
|
case CompressionPNGSpeed:
|
|
enc := &png.Encoder{CompressionLevel: png.BestSpeed}
|
|
err = enc.Encode(&bfr, img)
|
|
if err != nil {
|
|
return bytes.Buffer{}, "", exerr.Wrap(err, "").Build()
|
|
}
|
|
return bfr, "image/png", nil
|
|
case CompressionPNGBest:
|
|
enc := &png.Encoder{CompressionLevel: png.BestCompression}
|
|
err = enc.Encode(&bfr, img)
|
|
if err != nil {
|
|
return bytes.Buffer{}, "", exerr.Wrap(err, "").Build()
|
|
}
|
|
return bfr, "image/png", nil
|
|
case CompressionJPEG100:
|
|
err = jpeg.Encode(&bfr, img, &jpeg.Options{Quality: 100})
|
|
if err != nil {
|
|
return bytes.Buffer{}, "", exerr.Wrap(err, "").Build()
|
|
}
|
|
return bfr, "image/jpeg", nil
|
|
case CompressionJPEG90:
|
|
err = jpeg.Encode(&bfr, img, &jpeg.Options{Quality: 90})
|
|
if err != nil {
|
|
return bytes.Buffer{}, "", exerr.Wrap(err, "").Build()
|
|
}
|
|
return bfr, "image/jpeg", nil
|
|
case CompressionJPEG80:
|
|
err = jpeg.Encode(&bfr, img, &jpeg.Options{Quality: 80})
|
|
if err != nil {
|
|
return bytes.Buffer{}, "", exerr.Wrap(err, "").Build()
|
|
}
|
|
return bfr, "image/jpeg", nil
|
|
case CompressionJPEG70:
|
|
err = jpeg.Encode(&bfr, img, &jpeg.Options{Quality: 70})
|
|
if err != nil {
|
|
return bytes.Buffer{}, "", exerr.Wrap(err, "").Build()
|
|
}
|
|
return bfr, "image/jpeg", nil
|
|
case CompressionJPEG60:
|
|
err = jpeg.Encode(&bfr, img, &jpeg.Options{Quality: 60})
|
|
if err != nil {
|
|
return bytes.Buffer{}, "", exerr.Wrap(err, "").Build()
|
|
}
|
|
return bfr, "image/jpeg", nil
|
|
case CompressionJPEG50:
|
|
err = jpeg.Encode(&bfr, img, &jpeg.Options{Quality: 50})
|
|
if err != nil {
|
|
return bytes.Buffer{}, "", exerr.Wrap(err, "").Build()
|
|
}
|
|
return bfr, "image/jpeg", nil
|
|
case CompressionJPEG25:
|
|
err = jpeg.Encode(&bfr, img, &jpeg.Options{Quality: 25})
|
|
if err != nil {
|
|
return bytes.Buffer{}, "", exerr.Wrap(err, "").Build()
|
|
}
|
|
return bfr, "image/jpeg", nil
|
|
case CompressionJPEG10:
|
|
err = jpeg.Encode(&bfr, img, &jpeg.Options{Quality: 10})
|
|
if err != nil {
|
|
return bytes.Buffer{}, "", exerr.Wrap(err, "").Build()
|
|
}
|
|
return bfr, "image/jpeg", nil
|
|
case CompressionJPEG1:
|
|
err = jpeg.Encode(&bfr, img, &jpeg.Options{Quality: 1})
|
|
if err != nil {
|
|
return bytes.Buffer{}, "", exerr.Wrap(err, "").Build()
|
|
}
|
|
return bfr, "image/jpeg", nil
|
|
default:
|
|
return bytes.Buffer{}, "", exerr.New(exerr.TypeInternal, "unknown compression method: "+compression.String()).Build()
|
|
}
|
|
}
|
|
|
|
func ObjectFitImage(img image.Image, bbw float64, bbh float64, fit ImageFit, fillColor color.Color) (image.Image, error) {
|
|
|
|
iw := img.Bounds().Size().X
|
|
ih := img.Bounds().Size().Y
|
|
|
|
// [iw, ih] is the size of the image
|
|
// [bbw, bbh] is the target bounding box,
|
|
// - it specifies the target ratio
|
|
// - and the maximal target resolution
|
|
|
|
facW := float64(iw) / bbw
|
|
facH := float64(ih) / bbh
|
|
|
|
// facW is the ratio between iw and bbw
|
|
// - it is the factor by which the bounding box must be multiplied to reach the image size (in the x-axis)
|
|
//
|
|
// (same is true for facH, but for the height and y-axis)
|
|
|
|
if fit == ImageFitCover {
|
|
|
|
// image-fit:cover completely fills the target-bounding-box, it potentially cuts parts of the image away
|
|
|
|
// we use the smaller (!) value of facW and facH, because we want to have the smallest possible destination rect (due to file size)
|
|
// and because the image is made to completely fill the bounding-box, the smaller factor (= teh dimension the image is stretched more) is relevant
|
|
|
|
// but we cap `fac` at 1 (can be larger than 1)
|
|
// a value >1 would mean the final image resolution is biger than the bounding box, which we do not want.
|
|
|
|
// if the initial image (iw, ih) is already bigger than the bounding box (bbw, bbh), facW and facH are always >1 and fac will be 1
|
|
// which means we will simply use the bounding box as destination rect (and scale the image down)
|
|
|
|
fac := mathext.Clamp(mathext.Min(facW, facH), 0.0, 1.0)
|
|
|
|
// we scale the bounding box by fac (both dimension the same amount, to keep the bounding-box ratio)
|
|
|
|
w := int(math.Round(bbw * fac))
|
|
h := int(math.Round(bbh * fac))
|
|
|
|
img = imaging.Fill(img, w, h, imaging.Center, imaging.Lanczos)
|
|
|
|
newImg := image.NewRGBA(image.Rect(0, 0, w, h))
|
|
|
|
draw.Draw(newImg, newImg.Bounds(), &image.Uniform{C: fillColor}, image.Pt(0, 0), draw.Src)
|
|
draw.Draw(newImg, newImg.Bounds(), img, image.Pt(0, 0), draw.Over)
|
|
|
|
return newImg, nil
|
|
}
|
|
|
|
if fit == ImageFitContainCenter || fit == ImageFitContainTopLeft || fit == ImageFitContainTopRight || fit == ImageFitContainBottomLeft || fit == ImageFitContainBottomRight {
|
|
|
|
// image-fit:cover fills the target-bounding-box with the image, there is potentially empty-space, it potentially cuts parts of the image away
|
|
|
|
// we use the bigger (!) value of facW and facH,
|
|
// because the image is made to fit the bounding-box, the bigger factor (= the dimension the image is stretched less) is relevant
|
|
|
|
// but we cap `fac` at 1 (can be larger than 1)
|
|
// a value >1 would mean the final image resolution is biger than the bounding box, which we do not want.
|
|
|
|
// if the initial image (iw, ih) is already bigger than the bounding box (bbw, bbh), facW and facH are always >1 and fac will be 1
|
|
// which means we will simply use the bounding box as destination rect (and scale the image down)
|
|
|
|
facOut := mathext.Clamp(mathext.Max(facW, facH), 0.0, 1.0)
|
|
|
|
// we scale the bounding box by fac (both dimension the same amount, to keep the bounding-box ratio)
|
|
|
|
// [ow|oh] ==> size of output image (same ratio as bounding box [bbw|bbh])
|
|
|
|
ow := int(math.Round(bbw * facOut))
|
|
oh := int(math.Round(bbh * facOut))
|
|
|
|
facScale := mathext.Min(float64(ow)/float64(iw), float64(oh)/float64(ih))
|
|
|
|
// [dw|dh] ==> size of destination rect (where to draw source in output image) (same ratio as input image [iw|ih])
|
|
|
|
dw := int(math.Round(float64(iw) * facScale))
|
|
dh := int(math.Round(float64(ih) * facScale))
|
|
|
|
img = imaging.Resize(img, dw, dh, imaging.Lanczos)
|
|
|
|
var destBounds image.Rectangle
|
|
if fit == ImageFitContainCenter {
|
|
destBounds = image.Rect((ow-dw)/2, (oh-dh)/2, (ow-dw)/2+dw, (oh-dh)/2+dh)
|
|
} else if fit == ImageFitContainTopLeft {
|
|
destBounds = image.Rect(0, 0, dw, dh)
|
|
} else if fit == ImageFitContainTopRight {
|
|
destBounds = image.Rect(ow-dw, 0, ow, dh)
|
|
} else if fit == ImageFitContainBottomLeft {
|
|
destBounds = image.Rect(0, oh-dh, dw, oh)
|
|
} else if fit == ImageFitContainBottomRight {
|
|
destBounds = image.Rect(ow-dw, oh-dh, ow, oh)
|
|
}
|
|
|
|
newImg := image.NewRGBA(image.Rect(0, 0, ow, oh))
|
|
|
|
draw.Draw(newImg, newImg.Bounds(), &image.Uniform{C: fillColor}, image.Pt(0, 0), draw.Src)
|
|
draw.Draw(newImg, destBounds, img, image.Pt(0, 0), draw.Over)
|
|
|
|
return newImg, nil
|
|
}
|
|
|
|
if fit == ImageFitStretch {
|
|
|
|
// image-fit:stretch simply stretches the image to the bounding box
|
|
|
|
// we use the bigger value of [facW;facH], to (potentially) scale the bounding box down before applying it
|
|
// theoretically we could directly use [bbw, bbh] in the call to imaging.Resize,
|
|
// but if the image is (a lot) smaller than the bouding box it is useful to scale it down to reduce final pdf filesize
|
|
|
|
// we also cap fac at 1, because we never want the final rect to be bigger than the inputted bounding box (see comments at start of method)
|
|
|
|
fac := mathext.Clamp(mathext.Max(facW, facH), 0.0, 1.0)
|
|
|
|
// we scale the bounding box by fac (both dimension the same amount, to keep the bounding-box ratio)
|
|
|
|
w := int(math.Round(bbw * fac))
|
|
h := int(math.Round(bbh * fac))
|
|
|
|
img = imaging.Resize(img, w, h, imaging.Lanczos)
|
|
|
|
newImg := image.NewRGBA(image.Rect(0, 0, w, h))
|
|
|
|
draw.Draw(newImg, newImg.Bounds(), &image.Uniform{C: fillColor}, image.Pt(0, 0), draw.Src)
|
|
draw.Draw(newImg, newImg.Bounds(), img, image.Pt(0, 0), draw.Over)
|
|
|
|
return newImg, nil
|
|
}
|
|
|
|
return nil, exerr.New(exerr.TypeInternal, fmt.Sprintf("unknown image-fit: '%s'", fit)).Build()
|
|
}
|
|
|
|
func VerifyAndDecodeImage(data io.Reader, mime string) (image.Image, error) {
|
|
|
|
if mime == "image/jpeg" {
|
|
img, err := jpeg.Decode(data)
|
|
if err != nil {
|
|
return nil, exerr.Wrap(err, "failed to decode blob as jpeg").WithType(exerr.TypeInvalidImage).Build()
|
|
}
|
|
return img, nil
|
|
}
|
|
|
|
if mime == "image/png" {
|
|
img, err := png.Decode(data)
|
|
if err != nil {
|
|
return nil, exerr.Wrap(err, "failed to decode blob as png").WithType(exerr.TypeInvalidImage).Build()
|
|
}
|
|
return img, nil
|
|
}
|
|
|
|
return nil, exerr.New(exerr.TypeInvalidMimeType, fmt.Sprintf("unknown/invalid image mimetype: '%s'", mime)).Build()
|
|
}
|