diff --git a/exerr/data.go b/exerr/data.go index 3feab6e..17a4f6f 100644 --- a/exerr/data.go +++ b/exerr/data.go @@ -69,6 +69,9 @@ var ( TypeUnauthorized = NewType("UNAUTHORIZED", langext.Ptr(401)) TypeAuthFailed = NewType("AUTH_FAILED", langext.Ptr(401)) + TypeInvalidImage = NewType("IMAGEEXT_INVALID_IMAGE", langext.Ptr(400)) + TypeInvalidMimeType = NewType("IMAGEEXT_INVALID_MIMETYPE", langext.Ptr(400)) + // other values come from the downstream application that uses goext ) diff --git a/go.mod b/go.mod index 80dc584..1b52422 100644 --- a/go.mod +++ b/go.mod @@ -15,6 +15,7 @@ require ( ) require ( + github.com/disintegration/imaging v1.6.2 github.com/jung-kurt/gofpdf v1.16.2 golang.org/x/sync v0.7.0 ) @@ -53,6 +54,7 @@ require ( github.com/xdg-go/stringprep v1.0.4 // indirect github.com/youmark/pkcs8 v0.0.0-20240424034433-3c2c7870ae76 // indirect golang.org/x/arch v0.8.0 // indirect + golang.org/x/image v0.16.0 // indirect golang.org/x/net v0.25.0 // indirect golang.org/x/text v0.15.0 // indirect google.golang.org/protobuf v1.34.1 // indirect diff --git a/go.sum b/go.sum index 1f4dc1c..4b9ac97 100644 --- a/go.sum +++ b/go.sum @@ -43,6 +43,8 @@ github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSV github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c= +github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0= @@ -222,6 +224,10 @@ golang.org/x/crypto v0.22.0/go.mod h1:vr6Su+7cTlO45qkww3VDJlzDn0ctJvRgYbC2NvXHt+ golang.org/x/crypto v0.23.0 h1:dIJU/v2J8Mdglj/8rJ6UUOM3Zc9zLZxVZwwxMooUSAI= golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= golang.org/x/image v0.0.0-20190910094157-69e4b8554b2a/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= +golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8 h1:hVwzHzIUGRjiF7EcUjqNxk3NCfkPxbDKRdnNE1Rpg0U= +golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= +golang.org/x/image v0.16.0 h1:9kloLAKhUufZhA12l5fwnx2NZW39/we1UhBesW433jw= +golang.org/x/image v0.16.0/go.mod h1:ugSZItdV4nOxyqp56HmXwH0Ry0nBCpjnZdpDaIHdoPs= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= diff --git a/goextVersion.go b/goextVersion.go index de94d13..7759b57 100644 --- a/goextVersion.go +++ b/goextVersion.go @@ -1,5 +1,5 @@ package goext -const GoextVersion = "0.0.450" +const GoextVersion = "0.0.451" -const GoextVersionTimestamp = "2024-05-14T11:52:56+0200" +const GoextVersionTimestamp = "2024-05-14T12:46:49+0200" diff --git a/imageext/enums.go b/imageext/enums.go new file mode 100644 index 0000000..88f1367 --- /dev/null +++ b/imageext/enums.go @@ -0,0 +1,3 @@ +package imageext + +//go:generate go run ../_gen/enum-generate.go -- enums_gen.go diff --git a/imageext/enums_gen.go b/imageext/enums_gen.go new file mode 100644 index 0000000..b15f52d --- /dev/null +++ b/imageext/enums_gen.go @@ -0,0 +1,300 @@ +// Code generated by enum-generate.go DO NOT EDIT. + +package imageext + +import "gogs.mikescher.com/BlackForestBytes/goext/langext" +import "gogs.mikescher.com/BlackForestBytes/goext/enums" + +const ChecksumEnumGenerator = "1da5383c33ee442fd0b899369053f66bdc85bed2dbf906949d3edfeedfe13340" // GoExtVersion: 0.0.449 + +// ================================ ImageFit ================================ +// +// File: image.go +// StringEnum: true +// DescrEnum: false +// DataEnum: false +// + +var __ImageFitValues = []ImageFit{ + ImageFitStretch, + ImageFitCover, + ImageFitContainCenter, + ImageFitContainTopLeft, + ImageFitContainTopRight, + ImageFitContainBottomLeft, + ImageFitContainBottomRight, +} + +var __ImageFitVarnames = map[ImageFit]string{ + ImageFitStretch: "ImageFitStretch", + ImageFitCover: "ImageFitCover", + ImageFitContainCenter: "ImageFitContainCenter", + ImageFitContainTopLeft: "ImageFitContainTopLeft", + ImageFitContainTopRight: "ImageFitContainTopRight", + ImageFitContainBottomLeft: "ImageFitContainBottomLeft", + ImageFitContainBottomRight: "ImageFitContainBottomRight", +} + +func (e ImageFit) Valid() bool { + return langext.InArray(e, __ImageFitValues) +} + +func (e ImageFit) Values() []ImageFit { + return __ImageFitValues +} + +func (e ImageFit) ValuesAny() []any { + return langext.ArrCastToAny(__ImageFitValues) +} + +func (e ImageFit) ValuesMeta() []enums.EnumMetaValue { + return ImageFitValuesMeta() +} + +func (e ImageFit) String() string { + return string(e) +} + +func (e ImageFit) VarName() string { + if d, ok := __ImageFitVarnames[e]; ok { + return d + } + return "" +} + +func (e ImageFit) TypeName() string { + return "ImageFit" +} + +func (e ImageFit) PackageName() string { + return "media" +} + +func (e ImageFit) Meta() enums.EnumMetaValue { + return enums.EnumMetaValue{VarName: e.VarName(), Value: e, Description: nil} +} + +func ParseImageFit(vv string) (ImageFit, bool) { + for _, ev := range __ImageFitValues { + if string(ev) == vv { + return ev, true + } + } + return "", false +} + +func ImageFitValues() []ImageFit { + return __ImageFitValues +} + +func ImageFitValuesMeta() []enums.EnumMetaValue { + return []enums.EnumMetaValue{ + ImageFitStretch.Meta(), + ImageFitCover.Meta(), + ImageFitContainCenter.Meta(), + ImageFitContainTopLeft.Meta(), + ImageFitContainTopRight.Meta(), + ImageFitContainBottomLeft.Meta(), + ImageFitContainBottomRight.Meta(), + } +} + +// ================================ ImageCompresson ================================ +// +// File: image.go +// StringEnum: true +// DescrEnum: false +// DataEnum: false +// + +var __ImageCompressonValues = []ImageCompresson{ + CompressionPNGNone, + CompressionPNGSpeed, + CompressionPNGBest, + CompressionJPEG100, + CompressionJPEG90, + CompressionJPEG80, + CompressionJPEG70, + CompressionJPEG60, + CompressionJPEG50, + CompressionJPEG25, + CompressionJPEG10, + CompressionJPEG1, +} + +var __ImageCompressonVarnames = map[ImageCompresson]string{ + CompressionPNGNone: "CompressionPNGNone", + CompressionPNGSpeed: "CompressionPNGSpeed", + CompressionPNGBest: "CompressionPNGBest", + CompressionJPEG100: "CompressionJPEG100", + CompressionJPEG90: "CompressionJPEG90", + CompressionJPEG80: "CompressionJPEG80", + CompressionJPEG70: "CompressionJPEG70", + CompressionJPEG60: "CompressionJPEG60", + CompressionJPEG50: "CompressionJPEG50", + CompressionJPEG25: "CompressionJPEG25", + CompressionJPEG10: "CompressionJPEG10", + CompressionJPEG1: "CompressionJPEG1", +} + +func (e ImageCompresson) Valid() bool { + return langext.InArray(e, __ImageCompressonValues) +} + +func (e ImageCompresson) Values() []ImageCompresson { + return __ImageCompressonValues +} + +func (e ImageCompresson) ValuesAny() []any { + return langext.ArrCastToAny(__ImageCompressonValues) +} + +func (e ImageCompresson) ValuesMeta() []enums.EnumMetaValue { + return ImageCompressonValuesMeta() +} + +func (e ImageCompresson) String() string { + return string(e) +} + +func (e ImageCompresson) VarName() string { + if d, ok := __ImageCompressonVarnames[e]; ok { + return d + } + return "" +} + +func (e ImageCompresson) TypeName() string { + return "ImageCompresson" +} + +func (e ImageCompresson) PackageName() string { + return "media" +} + +func (e ImageCompresson) Meta() enums.EnumMetaValue { + return enums.EnumMetaValue{VarName: e.VarName(), Value: e, Description: nil} +} + +func ParseImageCompresson(vv string) (ImageCompresson, bool) { + for _, ev := range __ImageCompressonValues { + if string(ev) == vv { + return ev, true + } + } + return "", false +} + +func ImageCompressonValues() []ImageCompresson { + return __ImageCompressonValues +} + +func ImageCompressonValuesMeta() []enums.EnumMetaValue { + return []enums.EnumMetaValue{ + CompressionPNGNone.Meta(), + CompressionPNGSpeed.Meta(), + CompressionPNGBest.Meta(), + CompressionJPEG100.Meta(), + CompressionJPEG90.Meta(), + CompressionJPEG80.Meta(), + CompressionJPEG70.Meta(), + CompressionJPEG60.Meta(), + CompressionJPEG50.Meta(), + CompressionJPEG25.Meta(), + CompressionJPEG10.Meta(), + CompressionJPEG1.Meta(), + } +} + +// ================================ MimeCategory ================================ +// +// File: mime.go +// StringEnum: true +// DescrEnum: false +// DataEnum: false +// + +var __MimeCategoryValues = []MimeCategory{ + CatDocument, + CatImage, + CatVideo, + CatContainer, +} + +var __MimeCategoryVarnames = map[MimeCategory]string{ + CatDocument: "CatDocument", + CatImage: "CatImage", + CatVideo: "CatVideo", + CatContainer: "CatContainer", +} + +func (e MimeCategory) Valid() bool { + return langext.InArray(e, __MimeCategoryValues) +} + +func (e MimeCategory) Values() []MimeCategory { + return __MimeCategoryValues +} + +func (e MimeCategory) ValuesAny() []any { + return langext.ArrCastToAny(__MimeCategoryValues) +} + +func (e MimeCategory) ValuesMeta() []enums.EnumMetaValue { + return MimeCategoryValuesMeta() +} + +func (e MimeCategory) String() string { + return string(e) +} + +func (e MimeCategory) VarName() string { + if d, ok := __MimeCategoryVarnames[e]; ok { + return d + } + return "" +} + +func (e MimeCategory) TypeName() string { + return "MimeCategory" +} + +func (e MimeCategory) PackageName() string { + return "media" +} + +func (e MimeCategory) Meta() enums.EnumMetaValue { + return enums.EnumMetaValue{VarName: e.VarName(), Value: e, Description: nil} +} + +func ParseMimeCategory(vv string) (MimeCategory, bool) { + for _, ev := range __MimeCategoryValues { + if string(ev) == vv { + return ev, true + } + } + return "", false +} + +func MimeCategoryValues() []MimeCategory { + return __MimeCategoryValues +} + +func MimeCategoryValuesMeta() []enums.EnumMetaValue { + return []enums.EnumMetaValue{ + CatDocument.Meta(), + CatImage.Meta(), + CatVideo.Meta(), + CatContainer.Meta(), + } +} + +// ================================ ================= ================================ + +func AllPackageEnums() []enums.Enum { + return []enums.Enum{ + ImageFitStretch, // ImageFit + CompressionPNGNone, // ImageCompresson + CatDocument, // MimeCategory + } +} diff --git a/imageext/image.go b/imageext/image.go new file mode 100644 index 0000000..6d68fd5 --- /dev/null +++ b/imageext/image.go @@ -0,0 +1,317 @@ +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 := int(math.Round(bbw * facOut)) + oh := int(math.Round(bbh * facOut)) + + facScale := mathext.Min(float64(ow)/float64(iw), float64(oh)/float64(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, iw, dh) + } else if fit == ImageFitContainTopRight { + destBounds = image.Rect(ow-iw, 0, ow, dh) + } else if fit == ImageFitContainBottomLeft { + destBounds = image.Rect(0, oh-dh, iw, 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() +} diff --git a/wpdf/wpdfImage.go b/wpdf/wpdfImage.go index e1c0752..f26883f 100644 --- a/wpdf/wpdfImage.go +++ b/wpdf/wpdfImage.go @@ -3,13 +3,19 @@ package wpdf import ( "bytes" "github.com/jung-kurt/gofpdf" + "gogs.mikescher.com/BlackForestBytes/goext/imageext" "gogs.mikescher.com/BlackForestBytes/goext/langext" + "image" + "image/color" "net/http" ) type PDFImageRef struct { - Info *gofpdf.ImageInfoType - Name string + Info *gofpdf.ImageInfoType + Name string + Bin []byte + Image *image.Image + Mime string } type PDFImageRegisterOpt struct { @@ -48,6 +54,7 @@ func (b *WPDFBuilder) RegisterImage(bin []byte, opts ...*PDFImageRegisterOpt) *P imageType := "" readDpi := false allowNegativePosition := false + mime := "application/octet-stream" for _, opt := range opts { imageType = langext.Coalesce(opt.imageType, imageType) @@ -61,12 +68,26 @@ func (b *WPDFBuilder) RegisterImage(bin []byte, opts ...*PDFImageRegisterOpt) *P switch ct { case "image/jpg": imageType = "JPG" + mime = ct case "image/jpeg": imageType = "JPEG" + mime = ct case "image/png": imageType = "PNG" + mime = ct case "image/gif": imageType = "GIF" + mime = ct + } + } else { + switch imageType { + case "JPG": + case "JPEG": + mime = "image/jpeg" + case "PNG": + mime = "image/png" + case "GIF": + mime = "image/gif" } } @@ -79,8 +100,11 @@ func (b *WPDFBuilder) RegisterImage(bin []byte, opts ...*PDFImageRegisterOpt) *P info := b.b.RegisterImageOptionsReader(imgName, options, bytes.NewReader(bin)) return &PDFImageRef{ - Name: imgName, - Info: info, + Name: imgName, + Info: info, + Bin: bin, + Image: nil, + Mime: mime, } } @@ -95,6 +119,11 @@ type PDFImageOpt struct { imageType *string readDpi *bool allowNegativePosition *bool + imageFit *imageext.ImageFit + fillColor *color.Color + compression *imageext.ImageCompresson + reEncodePixelPerMM *float64 + crop *imageext.ImageCrop } func NewPDFImageOpt() *PDFImageOpt { @@ -151,7 +180,40 @@ func (opt *PDFImageOpt) AllowNegativePosition(v bool) *PDFImageOpt { return opt } +func (opt *PDFImageOpt) ImageFit(v imageext.ImageFit) *PDFImageOpt { + opt.imageFit = &v + return opt + +} + +func (opt *PDFImageOpt) FillColor(v color.Color) *PDFImageOpt { + opt.fillColor = &v + return opt +} + +func (opt *PDFImageOpt) Compression(v imageext.ImageCompresson) *PDFImageOpt { + opt.compression = &v + return opt +} + +func (opt *PDFImageOpt) ReEncodePixelPerMM(v float64) *PDFImageOpt { + opt.reEncodePixelPerMM = &v + return opt +} + +func (opt *PDFImageOpt) Crop(cropX float64, cropY float64, cropWidth float64, cropHeight float64) *PDFImageOpt { + opt.crop = &imageext.ImageCrop{ + CropX: cropX, + CropY: cropY, + CropWidth: cropWidth, + CropHeight: cropHeight, + } + return opt +} + func (b *WPDFBuilder) Image(img *PDFImageRef, opts ...*PDFImageOpt) { + var err error + x := b.GetX() y := b.GetY() w := img.Info.Width() @@ -162,6 +224,11 @@ func (b *WPDFBuilder) Image(img *PDFImageRef, opts ...*PDFImageOpt) { imageType := "" readDpi := false allowNegativePosition := false + reEncodePixelPerMM := 15.0 + var imageFit *imageext.ImageFit = nil + var fillColor color.Color = color.Transparent + compression := imageext.CompressionPNGSpeed + var crop *imageext.ImageCrop = nil for _, opt := range opts { x = langext.Coalesce(opt.x, x) @@ -174,6 +241,68 @@ func (b *WPDFBuilder) Image(img *PDFImageRef, opts ...*PDFImageOpt) { imageType = langext.Coalesce(opt.imageType, imageType) readDpi = langext.Coalesce(opt.readDpi, readDpi) allowNegativePosition = langext.Coalesce(opt.allowNegativePosition, allowNegativePosition) + imageFit = langext.CoalesceOpt(opt.imageFit, imageFit) + fillColor = langext.Coalesce(opt.fillColor, fillColor) + compression = langext.Coalesce(opt.compression, compression) + reEncodePixelPerMM = langext.Coalesce(opt.reEncodePixelPerMM, reEncodePixelPerMM) + crop = langext.CoalesceOpt(opt.crop, crop) + } + + regName := img.Name + + if imageFit != nil || fillColor != nil || crop != nil { + + var dataimg image.Image + if img.Image != nil { + dataimg = *img.Image + } else { + dataimg, err = imageext.VerifyAndDecodeImage(bytes.NewReader(img.Bin), img.Mime) + if err != nil { + b.b.SetError(err) + return + } + } + + if crop != nil { + dataimg, err = imageext.CropImage(dataimg, crop.CropX, crop.CropY, crop.CropWidth, crop.CropHeight) + if err != nil { + b.b.SetError(err) + return + } + } + + if imageFit != nil { + pdfPixelPerMillimeter := 15.0 + + pxw := w * pdfPixelPerMillimeter + pxh := h * pdfPixelPerMillimeter + + dataimg, err = imageext.ObjectFitImage(dataimg, pxw, pxh, *imageFit, fillColor) + if err != nil { + b.b.SetError(err) + return + } + } + + bfr, imgMime, err := imageext.EncodeImage(dataimg, compression) + if err != nil { + b.b.SetError(err) + return + } + + regName = regName + "_" + langext.MustRawHexUUID() + + switch imgMime { + case "image/jpeg": + imageType = "JPEG" + case "image/png": + imageType = "PNG" + case "image/gif": + imageType = "GIF" + } + + b.b.RegisterImageOptionsReader(regName, gofpdf.ImageOptions{ImageType: imageType}, &bfr) + } fpdfOpt := gofpdf.ImageOptions{ @@ -182,5 +311,5 @@ func (b *WPDFBuilder) Image(img *PDFImageRef, opts ...*PDFImageOpt) { AllowNegativePosition: allowNegativePosition, } - b.b.ImageOptions(img.Name, x, y, w, h, flow, fpdfOpt, link, linkStr) + b.b.ImageOptions(regName, x, y, w, h, flow, fpdfOpt, link, linkStr) }