diff --git a/README.md b/README.md index 8d0907a..afb5127 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ FFWebP is a small, single-binary CLI for converting images between formats, thin - Single binary: no external tools required - Auto-detects input codec and infers output from the file extension -- Supports AVIF, BMP, GIF, ICO, JPEG, JPEG XL, PCX, PNG, PNM (PBM/PGM/PPM/PAM), PSD (decode-only), QOI, TGA, TIFF, WebP, and XBM +- Supports AVIF, BMP, GIF, ICO, JPEG, JPEG XL, PCX, PNG, PNM (PBM/PGM/PPM/PAM), PSD (decode-only), QOI, SVG (decode-only), TGA, TIFF, WebP, and XBM - Lossy or lossless output with configurable quality - Thumbnail generation via Lanczos3 resampling - Per-codec flags for fine-grained control (see `ffwebp --help`) diff --git a/go.mod b/go.mod index 7324b71..0afe0ca 100644 --- a/go.mod +++ b/go.mod @@ -17,6 +17,8 @@ require ( github.com/samuel/go-pcx v0.0.0-20210515040514-6a5ce4d132f7 github.com/sergeymakinen/go-ico v1.0.0-beta.0 github.com/spakin/netpbm v1.3.2 + github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c + github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef github.com/xyproto/xbm v1.0.0 golang.org/x/image v0.18.0 ) @@ -26,4 +28,6 @@ require ( github.com/gopherjs/gopherjs v1.17.2 // indirect github.com/sergeymakinen/go-bmp v1.0.0 // indirect github.com/tetratelabs/wazero v1.9.0 // indirect + golang.org/x/net v0.0.0-20211118161319-6a13c67c3ce4 // indirect + golang.org/x/text v0.16.0 // indirect ) diff --git a/go.sum b/go.sum index 5b51d40..6b9f647 100644 --- a/go.sum +++ b/go.sum @@ -33,6 +33,10 @@ github.com/sergeymakinen/go-ico v1.0.0-beta.0 h1:m5qKH7uPKLdrygMWxbamVn+tl2HfiA3 github.com/sergeymakinen/go-ico v1.0.0-beta.0/go.mod h1:wQ47mTczswBO5F0NoDt7O0IXgnV4Xy3ojrroMQzyhUk= github.com/spakin/netpbm v1.3.2 h1:ZAb16Sw/+b4QeO9NokEvejVvbrYdF6DdcYJe0dKONL0= github.com/spakin/netpbm v1.3.2/go.mod h1:cVep9uXARFgAu2UU+0c+OE1J+eKmDN2hW0EN+tazkTA= +github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c h1:km8GpoQut05eY3GiYWEedbTT0qnSxrCjsVbb7yKY1KE= +github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c/go.mod h1:cNQ3dwVJtS5Hmnjxy6AgTPd0Inb3pW05ftPSX7NZO7Q= +github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef h1:Ch6Q+AZUxDBCVqdkI8FSpFyZDtCVBc2VmejdNrm5rRQ= +github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef/go.mod h1:nXTWP6+gD5+LUJ8krVhhoeHjvHTutPxMYl5SvkcnJNE= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/tetratelabs/wazero v1.9.0 h1:IcZ56OuxrtaEz8UYNRHBrUa9bYeX9oVY93KspZZBf/I= @@ -43,6 +47,10 @@ github.com/xyproto/xbm v1.0.0 h1:R5/A2+yhyy4V2c626bdIVfS1UNwE3efhiEBuKXU6u3A= github.com/xyproto/xbm v1.0.0/go.mod h1:m2xrjsNmxuzBrx6gs4rSgxgpJWXS01j9KxYgjxhcnoc= golang.org/x/image v0.18.0 h1:jGzIakQa/ZXI1I0Fxvaa9W7yP25TqT6cHIHn+6CqvSQ= golang.org/x/image v0.18.0/go.mod h1:4yyo5vMFQjVjUcVk4jEQcU9MGy/rulF5WvUILseCM2E= +golang.org/x/net v0.0.0-20211118161319-6a13c67c3ce4 h1:DZshvxDdVoeKIbudAdFEKi+f70l51luSy/7b76ibTY0= +golang.org/x/net v0.0.0-20211118161319-6a13c67c3ce4/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4= +golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/builtins/svg.go b/internal/builtins/svg.go new file mode 100644 index 0000000..4717717 --- /dev/null +++ b/internal/builtins/svg.go @@ -0,0 +1,8 @@ +//go:build svg || core || full +// +build svg core full + +package builtins + +import ( + _ "github.com/coalaura/ffwebp/internal/codec/svg" +) diff --git a/internal/codec/avif/avif.go b/internal/codec/avif/avif.go index f8ceae9..ccbd4b1 100644 --- a/internal/codec/avif/avif.go +++ b/internal/codec/avif/avif.go @@ -101,7 +101,7 @@ func (impl) Decode(reader io.Reader) (image.Image, error) { } func (impl) Encode(writer io.Writer, img image.Image, options opts.Common) error { - logx.Printf("avif: quality=%d, quality-alpha=%d, speed=%d, chroma=%d\n", options.Quality, qualityA, speed, chroma) + logx.Printf("avif: quality=%d quality-alpha=%d speed=%d chroma=%d\n", options.Quality, qualityA, speed, chroma) return avif.Encode(writer, img, avif.Options{ Quality: options.Quality, diff --git a/internal/codec/jpegxl/jpegxl.go b/internal/codec/jpegxl/jpegxl.go index 6654d06..6bed985 100644 --- a/internal/codec/jpegxl/jpegxl.go +++ b/internal/codec/jpegxl/jpegxl.go @@ -40,7 +40,7 @@ func (impl) Flags(flags []cli.Flag) []cli.Flag { return append(flags, &cli.IntFlag{ Name: "jpegxl.effort", - Usage: "JPEG XL: encode effort (1=fast .. 10=slow). Default 7", + Usage: "JPEGXL: encode effort (1=fast .. 10=slow). Default 7", Value: 7, Destination: &effort, Validator: func(value int) error { diff --git a/internal/codec/psd/psd.go b/internal/codec/psd/psd.go index 4a5c017..4581dcb 100644 --- a/internal/codec/psd/psd.go +++ b/internal/codec/psd/psd.go @@ -64,7 +64,7 @@ func (impl) Sniff(reader io.ReaderAt) (int, []byte, error) { } func (impl) Decode(reader io.Reader) (image.Image, error) { - logx.Printf("psd: skipMerged=%t\n", skipMerged) + logx.Printf("psd: skip-merged=%t\n", skipMerged) img, _, err := psd.Decode(reader, &psd.DecodeOptions{ SkipMergedImage: skipMerged, diff --git a/internal/codec/svg/svg.go b/internal/codec/svg/svg.go new file mode 100644 index 0000000..2c8784a --- /dev/null +++ b/internal/codec/svg/svg.go @@ -0,0 +1,163 @@ +package svg + +import ( + "errors" + "fmt" + "image" + "image/draw" + "io" + "strings" + + "github.com/coalaura/ffwebp/internal/codec" + "github.com/coalaura/ffwebp/internal/logx" + "github.com/coalaura/ffwebp/internal/opts" + "github.com/srwiley/oksvg" + "github.com/srwiley/rasterx" + "github.com/urfave/cli/v3" +) + +var ( + svgWidth int + svgHeight int + svgBackground string +) + +func init() { + codec.Register(impl{}) +} + +type impl struct{} + +func (impl) String() string { + return "svg" +} + +func (impl) Extensions() []string { + return []string{"svg"} +} + +func (impl) CanEncode() bool { + return false +} + +func (impl) Flags(flags []cli.Flag) []cli.Flag { + return append(flags, + &cli.IntFlag{ + Name: "svg.width", + Usage: "SVG: output width in pixels (0 = auto)", + Value: 0, + Destination: &svgWidth, + }, + &cli.IntFlag{ + Name: "svg.height", + Usage: "SVG: output height in pixels (0 = auto)", + Value: 0, + Destination: &svgHeight, + }, + &cli.StringFlag{ + Name: "svg.background", + Usage: "SVG: background color", + Value: "", + Destination: &svgBackground, + }, + ) +} + +func (impl) Sniff(reader io.ReaderAt) (int, []byte, error) { + buf := make([]byte, 128) + + n, err := reader.ReadAt(buf, 0) + if err != nil && err != io.EOF { + return 0, nil, err + } + + buf = buf[:n] + + if strings.Contains(strings.ToLower(string(buf)), " 64 { + sniff = sniff[:64] + } + + return 80, sniff, nil + } + + return 0, nil, nil +} + +func (impl) Decode(r io.Reader) (image.Image, error) { + icon, err := oksvg.ReadIconStream(r) + if err != nil { + return nil, err + } + + vbw := int(icon.ViewBox.W) + vbh := int(icon.ViewBox.H) + + var w, h int + + switch { + case svgWidth > 0 && svgHeight > 0: + w, h = svgWidth, svgHeight + case svgWidth > 0 && svgHeight == 0: + w = svgWidth + + if vbw > 0 && vbh > 0 { + h = int(float64(svgWidth) * float64(vbh) / float64(vbw)) + } else { + h = svgWidth + } + case svgHeight > 0 && svgWidth == 0: + h = svgHeight + + if vbw > 0 && vbh > 0 { + w = int(float64(svgHeight) * float64(vbw) / float64(vbh)) + } else { + w = svgHeight + } + default: + if vbw > 0 && vbh > 0 { + w, h = vbw, vbh + } else { + w, h = 256, 256 + } + } + + if w <= 0 { + w = 256 + } + + if h <= 0 { + h = 256 + } + + logx.Printf("svg: size=%dx%d background=%s\n", w, h, svgBackground) + + rgba := image.NewRGBA(image.Rect(0, 0, w, h)) + + if svgBackground != "" && !strings.EqualFold(svgBackground, "transparent") { + bgc, err := oksvg.ParseSVGColor(svgBackground) + if err != nil { + return nil, err + } else if bgc == nil { + return nil, fmt.Errorf("invalid svg.background: %s", svgBackground) + } + + draw.Draw(rgba, rgba.Bounds(), &image.Uniform{bgc}, image.Point{}, draw.Src) + } + + icon.SetTarget(0, 0, float64(w), float64(h)) + + scanner := rasterx.NewScannerGV(w, h, rgba, rgba.Bounds()) + + d := rasterx.NewDasher(w, h, scanner) + + icon.Draw(d, 1.0) + + return rgba, nil +} + +func (impl) Encode(w io.Writer, img image.Image, options opts.Common) error { + return errors.New("svg: encoding not supported") +} diff --git a/test/image.svg b/test/image.svg new file mode 100644 index 0000000..651ce57 --- /dev/null +++ b/test/image.svg @@ -0,0 +1,72 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + 0.59 + + + + + + + + + + + +0.30 + + + + + + + + + + + +0.11 + + + + + + + + + + + 100% + 89% + 70% + 59% + 41% + 30% + 11% + 0% + + TEST + TEST +