From 03761362647c80b649e53b3efe399abb5c195818 Mon Sep 17 00:00:00 2001 From: Ronsor Date: Tue, 3 Jan 2023 11:56:19 -0700 Subject: [PATCH] Add TIFF & BMP file formats, add parameter grouping for CLI tool --- format/png/writer.go | 17 +++++++-- go.mod | 1 + go.sum | 2 + henshin/bmp_codec.go | 57 ++++++++++++++++++++++++++++ henshin/codecs.go | 7 ++++ henshin/resize_test.go | 14 +++++++ henshin/tiff_codec.go | 60 +++++++++++++++++++++++++++++ henshin/wand.go | 1 + main.go | 86 +++++++++++++++++++++++++++++++++++++----- 9 files changed, 232 insertions(+), 13 deletions(-) create mode 100644 henshin/bmp_codec.go create mode 100644 henshin/resize_test.go create mode 100644 henshin/tiff_codec.go diff --git a/format/png/writer.go b/format/png/writer.go index 5503549..1b03bf7 100644 --- a/format/png/writer.go +++ b/format/png/writer.go @@ -57,6 +57,7 @@ type encoder struct { zs *zstd.Encoder zsLevel zstd.EncoderLevel bw *bufio.Writer + useZstd bool } // CompressionLevel indicates the compression level. @@ -203,7 +204,7 @@ func (e *encoder) writePLTEAndTRNS(p color.Palette) { // This method should only be called from writeIDATs (via writeImage). // No other code should treat an encoder as an io.Writer. func (e *encoder) Write(b []byte) (int, error) { - if e.enc.UseZstd { + if e.useZstd { e.writeChunk(b, "ZDAT") } else { e.writeChunk(b, "IDAT") @@ -316,7 +317,7 @@ func zeroMemory(v []uint8) { func (e *encoder) writeImage(w io.Writer, m image.Image, cb int, level int) error { var cw io.Writer - if !e.enc.UseZstd { + if !e.useZstd { if e.zw == nil || e.zwLevel != level { zw, err := zlib.NewWriterLevel(w, level) if err != nil { @@ -561,7 +562,7 @@ func (e *encoder) writeIDATorZDATs() { } else { e.bw.Reset(e) } - if e.enc.UseZstd { + if e.useZstd { e.err = e.writeImage(e.bw, e.m, e.cb, levelToZstd(e.enc.CompressionLevel)) } else { e.err = e.writeImage(e.bw, e.m, e.cb, levelToZlib(e.enc.CompressionLevel)) @@ -623,6 +624,10 @@ type Chunk struct { type EncodeOptions struct { // CustomChunks specifies custom chunks to include in the PNG file. CustomChunks []Chunk + + // FallbackImage specifies a fallback image when Zstd compression is + // used. + FallbackImage image.Image } // Encode writes the Image m to w in PNG format. Any Image may be @@ -662,6 +667,7 @@ func (enc *Encoder) EncodeWithOptions(w io.Writer, m image.Image, o *EncodeOptio } e.enc = enc + e.useZstd = enc.UseZstd e.w = w e.m = m @@ -712,6 +718,11 @@ func (enc *Encoder) EncodeWithOptions(w io.Writer, m image.Image, o *EncodeOptio e.writePLTEAndTRNS(pal) } e.writeIDATorZDATs() + if o.FallbackImage != nil && enc.UseZstd { + e.useZstd = false + e.m = o.FallbackImage + e.writeIDATorZDATs() + } for _, c := range o.CustomChunks { if c.AfterIDAT { e.writeChunk(c.Data, c.Name) diff --git a/go.mod b/go.mod index 21599b9..0238cea 100644 --- a/go.mod +++ b/go.mod @@ -7,5 +7,6 @@ require ( github.com/pborman/getopt/v2 v2.1.0 github.com/spakin/netpbm v1.3.0 github.com/xfmoulet/qoi v0.2.0 + golang.org/x/exp v0.0.0-20221230185412-738e83a70c30 golang.org/x/image v0.2.0 ) diff --git a/go.sum b/go.sum index 7ca451e..0acef80 100644 --- a/go.sum +++ b/go.sum @@ -9,6 +9,8 @@ github.com/xfmoulet/qoi v0.2.0/go.mod h1:uuPUygmV7o8qy7PhiaGAQX0iLiqoUvFEUKjwUFt github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/exp v0.0.0-20221230185412-738e83a70c30 h1:m9O6OTJ627iFnN2JIWfdqlZCzneRO6EEBsHXI25P8ws= +golang.org/x/exp v0.0.0-20221230185412-738e83a70c30/go.mod h1:CxIveKay+FTh1D0yPZemJVgC/95VzuuOLq5Qi4xnoYc= golang.org/x/image v0.2.0 h1:/DcQ0w3VHKCC5p0/P2B0JpAZ9Z++V2KOo2fyU89CXBQ= golang.org/x/image v0.2.0/go.mod h1:la7oBXb9w3YFjBqaAwtynVioc1ZvOnNteUNrifGNmAI= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= diff --git a/henshin/bmp_codec.go b/henshin/bmp_codec.go new file mode 100644 index 0000000..1a614e4 --- /dev/null +++ b/henshin/bmp_codec.go @@ -0,0 +1,57 @@ +// Copyright 2023 Ronsor Labs. All rights reserved. + +package henshin + +import ( + "image" + "io" + + "golang.org/x/image/bmp" +) + +func init() { + RegisterCodec(&BMPCodec{}) +} + +// BMPCodec is the BMP codec. +type BMPCodec struct{} + +// New returns a new instance of BMPCodec. +func (c *BMPCodec) New() Codec { return &BMPCodec{} } + +// Name returns the name of the BMP codec: "bmp" +func (c *BMPCodec) Name() string { return "bmp" } + +// Aliases returns alternate names for the BMP codec +func (c *BMPCodec) Aliases() []string { + return []string{"dib"} +} + +// Magic returns magic strings that identify BMP data. +func (c *BMPCodec) Magic() []string { + return []string{"BM????\x00\x00\x00\x00"} +} + +// Decode decodes a BMP image according to the options specified. +func (c *BMPCodec) Decode(r io.Reader, d *DecodeOptions) (image.Image, error) { + return bmp.Decode(r) +} + +// DecodeConfig returns the color model and dimensions of a BMP image +// without decoding the image. +func (c *BMPCodec) DecodeConfig(r io.Reader, d *DecodeOptions) (image.Config, error) { + return bmp.DecodeConfig(r) +} + +// Encode encodes a BMP image according to the options specified. +func (c *BMPCodec) Encode(w io.Writer, i image.Image, o *EncodeOptions) error { + if o == nil { o = DefaultEncodeOptions() } + + return bmp.Encode(w, i) +} + +var ( + _ Decoder = &BMPCodec{} + _ Encoder = &BMPCodec{} + _ CodecWithAliases = &BMPCodec{} +) diff --git a/henshin/codecs.go b/henshin/codecs.go index 1fd0a14..30cb772 100644 --- a/henshin/codecs.go +++ b/henshin/codecs.go @@ -32,6 +32,13 @@ type CodecWithAliases interface { Aliases() []string } +// CodecWithParamParser is an image codec that allows getting +// codec-specific parameters from a string. +type CodecWithParamParser interface { + Codec + ParseParams(opt string) (any, error) +} + // Decoder is an image codec that can decode. type Decoder interface { Codec diff --git a/henshin/resize_test.go b/henshin/resize_test.go new file mode 100644 index 0000000..2cf95d9 --- /dev/null +++ b/henshin/resize_test.go @@ -0,0 +1,14 @@ +// Copyright 2023 Ronsor Labs. All rights reserved. + +package henshin + +import ( + "testing" +) + +func TestAreaFit(t *testing.T) { + w, h := areaFit(1920, 1080, 1024*1024) + if w != 1365 && h != 768 { + t.Errorf("Expected (1365, 768) but got (%d, %d)", w, h) + } +} diff --git a/henshin/tiff_codec.go b/henshin/tiff_codec.go new file mode 100644 index 0000000..fcec3bc --- /dev/null +++ b/henshin/tiff_codec.go @@ -0,0 +1,60 @@ +// Copyright 2023 Ronsor Labs. All rights reserved. + +package henshin + +import ( + "image" + "io" + + "golang.org/x/image/tiff" +) + +func init() { + RegisterCodec(&TIFFCodec{}) +} + +// TIFFCodec is the TIFF codec. +type TIFFCodec struct{} + +// New returns a new instance of TIFFCodec. +func (c *TIFFCodec) New() Codec { return &TIFFCodec{} } + +// Name returns the name of the TIFF codec: "tiff" +func (c *TIFFCodec) Name() string { return "tiff" } + +// Aliases returns alternate names for the TIFF codec +func (c *TIFFCodec) Aliases() []string { + return []string{"tif"} +} + +// Magic returns magic strings that identify TIFF data. +func (c *TIFFCodec) Magic() []string { + return []string{"II\x2A\x00", "MM\x00\x2A"} +} + +// Decode decodes a TIFF image according to the options specified. +func (c *TIFFCodec) Decode(r io.Reader, d *DecodeOptions) (image.Image, error) { + return tiff.Decode(r) +} + +// DecodeConfig returns the color model and dimensions of a TIFF image +// without decoding the image. +func (c *TIFFCodec) DecodeConfig(r io.Reader, d *DecodeOptions) (image.Config, error) { + return tiff.DecodeConfig(r) +} + +// Encode encodes a TIFF image according to the options specified. +func (c *TIFFCodec) Encode(w io.Writer, i image.Image, o *EncodeOptions) error { + if o == nil { o = DefaultEncodeOptions() } + + var tiffOpt tiff.Options + // TODO: allow setting these options + + return tiff.Encode(w, i, &tiffOpt) +} + +var ( + _ Decoder = &TIFFCodec{} + _ Encoder = &TIFFCodec{} + _ CodecWithAliases = &TIFFCodec{} +) diff --git a/henshin/wand.go b/henshin/wand.go index c03951e..7667b95 100644 --- a/henshin/wand.go +++ b/henshin/wand.go @@ -240,6 +240,7 @@ func (w *Wand) property(key string) (val string) { val = w.md.Comments[0] } case "%": val = "%" + case ";": val = "," } if val == "" { if strings.HasPrefix(key, "C:") || strings.HasPrefix(key, "comment:") { diff --git a/main.go b/main.go index cb9a4aa..a92bfd2 100644 --- a/main.go +++ b/main.go @@ -26,6 +26,8 @@ var ( identifyFormatString = "%wx%h, hash: %H, comment: %c" filterArgs FilterArgs + + groupParamOpen, groupParamClose *int ) // FilterArgs is a set of filtering arguments. @@ -50,20 +52,69 @@ func init() { getopt.FlagLong(&identifyFormatString, "identify-format", 0, "Format string for --identify output") - getopt.FlagLong(&filterArgs.Strip, "strip", 'S', "Strip metadata from image") - getopt.FlagLong(&filterArgs.AddComments, "comment", 'C', "Add comment to image metadata") - getopt.FlagLong(&filterArgs.SetComments, "set-comment", 0, "Set comments for image metadata") - getopt.FlagLong(&filterArgs.Crop, "crop", 'c', "Crop image") - getopt.FlagLong(&filterArgs.Resize, "resize", 'r', "Resize image") - getopt.FlagLong(&filterArgs.CompressionLevel, "compress", 0, "Compression level, if applicable (0-100)") - filterArgs.CompressionLevel = -1 // Set to default + groupParamOpen = getopt.CounterLong("group", '(', "Open filter parameter group") + groupParamClose = getopt.CounterLong("end-group", ')', "Close filter parameter group") + + initFilterArgs(&filterArgs, getopt.CommandLine) getopt.SetParameters("[images ...] [output path]") } -func processFilterArgs(wand *henshin.Wand, fa *FilterArgs) { - wand.ForceRGBA() +func initFilterArgs(filterArgs *FilterArgs, optSet *getopt.Set) { + optSet.FlagLong(&filterArgs.Strip, "strip", 'S', "Strip metadata from image") + optSet.FlagLong(&filterArgs.AddComments, "comment", 'C', "Add comment to image metadata") + optSet.FlagLong(&filterArgs.SetComments, "set-comment", 0, "Set comments for image metadata") + optSet.FlagLong(&filterArgs.Crop, "crop", 'c', "Crop image") + optSet.FlagLong(&filterArgs.Resize, "resize", 'r', "Resize image") + optSet.FlagLong(&filterArgs.CompressionLevel, "compress", 0, "Compression level, if applicable (0-100)") + filterArgs.CompressionLevel = -1 // Set to default +} + +func parseFilterArgs(args []string) (ret []*FilterArgs) { + groupStartIdx := -1 + groupEndIdx := -1 + groupCount := 1 + + for i, arg := range args { + if arg == "--group" || arg == "-(" { + groupStartIdx = i + } else if arg == "--end-group" || arg == "-)" { + groupEndIdx = i + } + if groupStartIdx != -1 && groupEndIdx != -1 { + if (groupEndIdx - groupStartIdx) == -1 { + groupStartIdx = -1 + groupEndIdx = -1 + groupCount++ + continue + } + + optSet := getopt.New() + filterArgs := &FilterArgs{} + initFilterArgs(filterArgs, optSet) + + section := args[groupStartIdx:groupEndIdx] + err := optSet.Getopt(section, nil) + if err != nil { + fmt.Fprintf(os.Stderr, "Filter group %d: %v\n", groupCount, err) + optSet.SetProgram("majokko -(") + optSet.SetParameters("-)") + optSet.PrintUsage(os.Stderr) + os.Exit(1) + } + ret = append(ret, filterArgs) + + groupStartIdx = -1 + groupEndIdx = -1 + groupCount++ + } + } + + return +} + +func processFilterArgs(wand *henshin.Wand, fa *FilterArgs) { if fa.Strip { wand.Strip() } @@ -134,7 +185,16 @@ func actionConvert(wand *henshin.Wand, logPrefix string, maxArg int, args []stri outFile = filepath.Join(outFile, filepath.Base(inFile)) } - processFilterArgs(wand, &filterArgs) + wand.ForceRGBA() + + faGroups := parseFilterArgs(os.Args) + if faGroups == nil { + processFilterArgs(wand, &filterArgs) + } else { + for _, fa := range faGroups { + processFilterArgs(wand, fa) + } + } err := wand.WriteImage(outFile) if err != nil { @@ -189,6 +249,12 @@ func main() { return } + if *groupParamOpen != *groupParamClose { + fmt.Fprintln(os.Stderr, "Unbalanced filter groups.") + getopt.Usage() + os.Exit(1) + } + if !doConvert { doConvert = !doIdentify }