diff --git a/README.md b/README.md index 7763893..4c4ef3e 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ FFWebP is a command line utility for converting images between multiple formats. ## Features - Pure Go implementation with no external runtime dependencies -- Supports AVIF, BMP, GIF, ICO, JPEG, JPEGXL, PNG, TIFF and WebP +- Supports AVIF, BMP, GIF, ICO, JPEG, JPEGXL, PNG, PNM (PBM/PGM/PPM/PAM), TIFF and WebP - Lossy or lossless output with configurable quality - Output codec selected from the output file extension when `--codec` is omitted - Full set of format-specific flags for every supported format (see `ffwebp --help`) diff --git a/go.mod b/go.mod index ecf41a8..7eae81c 100644 --- a/go.mod +++ b/go.mod @@ -10,6 +10,7 @@ require ( github.com/gen2brain/webp v0.5.5 github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 github.com/sergeymakinen/go-ico v1.0.0-beta.0 + github.com/spakin/netpbm v1.3.2 golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8 ) diff --git a/go.sum b/go.sum index 3132db6..73f06a5 100644 --- a/go.sum +++ b/go.sum @@ -16,6 +16,8 @@ github.com/sergeymakinen/go-bmp v1.0.0 h1:SdGTzp9WvCV0A1V0mBeaS7kQAwNLdVJbmHlqNW github.com/sergeymakinen/go-bmp v1.0.0/go.mod h1:/mxlAQZRLxSvJFNIEGGLBE/m40f3ZnUifpgVDlcUIEY= github.com/sergeymakinen/go-ico v1.0.0-beta.0 h1:m5qKH7uPKLdrygMWxbamVn+tl2HfiA3K6MFJw4GfZvQ= 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/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= diff --git a/internal/builtins/pnm.go b/internal/builtins/pnm.go new file mode 100644 index 0000000..5ec0f34 --- /dev/null +++ b/internal/builtins/pnm.go @@ -0,0 +1,8 @@ +//go:build pnm || core || full +// +build pnm core full + +package builtins + +import ( + _ "github.com/coalaura/ffwebp/internal/codec/pnm" +) diff --git a/internal/codec/avif/avif.go b/internal/codec/avif/avif.go index 1af4882..8c03c6d 100644 --- a/internal/codec/avif/avif.go +++ b/internal/codec/avif/avif.go @@ -49,6 +49,7 @@ func (impl) Flags(flags []cli.Flag) []cli.Flag { if v < 0 || v > 100 { return fmt.Errorf("invalid avif.quality-alpha: %d", v) } + return nil }, }, @@ -61,6 +62,7 @@ func (impl) Flags(flags []cli.Flag) []cli.Flag { if v < 0 || v > 10 { return fmt.Errorf("invalid avif.speed: %d", v) } + return nil }, }, @@ -73,6 +75,7 @@ func (impl) Flags(flags []cli.Flag) []cli.Flag { if v != 444 && v != 422 && v != 420 { return fmt.Errorf("invalid avif.chroma: %d", v) } + return nil }, }, diff --git a/internal/codec/jpegxl/jpegxl.go b/internal/codec/jpegxl/jpegxl.go index 221f678..6654d06 100644 --- a/internal/codec/jpegxl/jpegxl.go +++ b/internal/codec/jpegxl/jpegxl.go @@ -47,6 +47,7 @@ func (impl) Flags(flags []cli.Flag) []cli.Flag { if value < 1 || value > 10 { return fmt.Errorf("invalid jpegxl.effort: %d", value) } + return nil }, }, diff --git a/internal/codec/pnm/pnm.go b/internal/codec/pnm/pnm.go new file mode 100644 index 0000000..17a102b --- /dev/null +++ b/internal/codec/pnm/pnm.go @@ -0,0 +1,184 @@ +package pnm + +import ( + "fmt" + "image" + "io" + "strings" + + "github.com/spakin/netpbm" + + "github.com/coalaura/ffwebp/internal/codec" + "github.com/coalaura/ffwebp/internal/logx" + "github.com/coalaura/ffwebp/internal/opts" + "github.com/urfave/cli/v3" +) + +var ( + plain bool + maxValue uint16 + formatStr string + tupleType string +) + +func init() { + codec.Register(impl{}) +} + +type impl struct{} + +func (impl) String() string { + return "pnm" +} + +func (impl) Extensions() []string { + return []string{"ppm", "pgm", "pnm", "pbm", "pam"} +} + +func (impl) CanEncode() bool { + return true +} + +func (impl) Flags(flags []cli.Flag) []cli.Flag { + return append(flags, + &cli.BoolFlag{ + Name: "pnm.plain", + Usage: "PNM: produce plain (ASCII) format (P1/P2/P3/P7 with ASCII raster)", + Value: false, + Destination: &plain, + }, + &cli.Uint16Flag{ + Name: "pnm.maxval", + Usage: "PNM: maximum sample value (1..65535). Controls 1- or 2-byte samples for binary formats", + Value: 255, + Destination: &maxValue, + }, + &cli.StringFlag{ + Name: "pnm.format", + Usage: "PNM: force output subformat (pbm, pgm, ppm, pam). If empty the codec will use the extension of the output file or infer it from the image.", + Value: "", + Destination: &formatStr, + }, + &cli.StringFlag{ + Name: "pnm.pam-tupletype", + Usage: "PNM: when writing PAM (P7), set TUPLETYPE (e.g. RGB, RGB_ALPHA, GRAYSCALE). If empty a sensible value is chosen.", + Value: "", + Destination: &tupleType, + }, + ) +} + +func (impl) Sniff(reader io.ReaderAt) (int, []byte, error) { + buf := make([]byte, 2) + + if _, err := reader.ReadAt(buf, 0); err != nil { + return 0, nil, err + } + + if buf[0] == 'P' && buf[1] >= '1' && buf[1] <= '7' { + return 100, buf, nil + } + + return 0, nil, nil +} + +func (impl) Decode(r io.Reader) (image.Image, error) { + return netpbm.Decode(r, &netpbm.DecodeOptions{ + Exact: true, + }) +} + +func (impl) Encode(w io.Writer, img image.Image, common opts.Common) error { + var ( + format netpbm.Format + found bool + ) + + if formatStr != "" { + f, ok := pnmFormat(formatStr) + if !ok { + return fmt.Errorf("invalid pnm.format: %q", formatStr) + } + + found = true + format = f + } else if common.OutputExtension != "" { + f, ok := pnmFormat(common.OutputExtension) + if ok { + found = true + format = f + } + } + + if !found { + if imageHasAlpha(img) { + format = netpbm.PAM + } else if imageIsGrayscale(img) { + format = netpbm.PGM + } else { + format = netpbm.PPM + } + } + + opts := &netpbm.EncodeOptions{ + Format: format, + Plain: plain, + MaxValue: maxValue, + TupleType: tupleType, + } + + if opts.Format == netpbm.PBM { + opts.MaxValue = 1 + } + + logx.Printf("pnm: format=%s plain=%t maxval=%d tupltype=%q\n", opts.Format.String(), opts.Plain, opts.MaxValue, opts.TupleType) + + return netpbm.Encode(w, img, opts) +} + +func pnmFormat(format string) (netpbm.Format, bool) { + switch strings.ToLower(format) { + case "pbm": + return netpbm.PBM, true + case "pgm": + return netpbm.PGM, true + case "ppm": + return netpbm.PPM, true + case "pam": + return netpbm.PAM, true + } + + return 0, false +} + +func imageHasAlpha(img image.Image) bool { + bounds := img.Bounds() + + for y := bounds.Min.Y; y < bounds.Max.Y; y++ { + for x := bounds.Min.X; x < bounds.Max.X; x++ { + _, _, _, a := img.At(x, y).RGBA() + + if a != 0xFFFF { + return true + } + } + } + + return false +} + +func imageIsGrayscale(img image.Image) bool { + bounds := img.Bounds() + + for y := bounds.Min.Y; y < bounds.Max.Y; y++ { + for x := bounds.Min.X; x < bounds.Max.X; x++ { + r, g, b, _ := img.At(x, y).RGBA() + + if r != g || r != b { + return false + } + } + } + + return true +}