From 3f0ae41456cc20cdbd0637a32da5c4b4ac95b596 Mon Sep 17 00:00:00 2001 From: Milan Nikolic Date: Wed, 24 Jun 2026 07:55:16 +0200 Subject: [PATCH] Add ZipLevel, issue #48 --- cbconvert.go | 3 +++ cbconvert_archive.go | 11 +++++++++ cbconvert_test.go | 52 +++++++++++++++++++++++++++++++++++++++ cmd/cbconvert-gui/main.go | 50 ++++++++++++++++++++++++++++++++++++- cmd/cbconvert/main.go | 3 ++- 5 files changed, 117 insertions(+), 2 deletions(-) diff --git a/cbconvert.go b/cbconvert.go index f85bcd1..49a53d5 100644 --- a/cbconvert.go +++ b/cbconvert.go @@ -23,6 +23,8 @@ type Options struct { Format string // Archive format, valid values are zip, tar Archive string + // ZIP compression level: -1 default, 0 store (no compression), 1-9 deflate (1 fastest, 9 smallest) + ZipLevel int // JPEG image quality Quality int // Encoder speed/effort, format-specific: webp method 0-6, avif speed 0-10, jxl effort 1-10; -1 uses the format default @@ -137,6 +139,7 @@ func NewOptions() Options { o.Archive = "zip" o.Quality = 75 o.Effort = -1 + o.ZipLevel = -1 o.Filter = 2 return o diff --git a/cbconvert_archive.go b/cbconvert_archive.go index 0fe3eac..e999e25 100644 --- a/cbconvert_archive.go +++ b/cbconvert_archive.go @@ -3,6 +3,7 @@ package cbconvert import ( "archive/tar" "archive/zip" + "compress/flate" "context" "fmt" "io" @@ -50,6 +51,13 @@ func (c *Converter) archiveSaveZip(fileName string) error { z := zip.NewWriter(zipFile) + if c.Opts.ZipLevel >= 1 { + level := c.Opts.ZipLevel + z.RegisterCompressor(zip.Deflate, func(out io.Writer) (io.WriteCloser, error) { + return flate.NewWriter(out, level) + }) + } + files, err := os.ReadDir(c.Workdir) if err != nil { return fmt.Errorf("archiveSaveZip: %w", err) @@ -72,6 +80,9 @@ func (c *Converter) archiveSaveZip(fileName string) error { } zipInfo.Method = zip.Deflate + if c.Opts.ZipLevel == 0 { + zipInfo.Method = zip.Store + } w, err := z.CreateHeader(zipInfo) if err != nil { return fmt.Errorf("archiveSaveZip: %w", err) diff --git a/cbconvert_test.go b/cbconvert_test.go index 165c4fa..6a0600b 100644 --- a/cbconvert_test.go +++ b/cbconvert_test.go @@ -203,6 +203,58 @@ func TestConvertTar(t *testing.T) { } } +func TestZipLevel(t *testing.T) { + convertWith := func(level int) *zip.ReadCloser { + tmpDir, err := os.MkdirTemp(os.TempDir(), "cbc") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { os.RemoveAll(tmpDir) }) + + opts := NewOptions() + opts.OutDir = tmpDir + opts.ZipLevel = level + opts.NoConvert = true + + conv := New(opts) + + files, err := conv.Files([]string{"testdata/test.cbz"}) + if err != nil { + t.Fatal(err) + } + for _, file := range files { + if err := conv.Convert(file); err != nil { + t.Fatal(err) + } + } + + zr, err := zip.OpenReader(filepath.Join(tmpDir, "test.cbz")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { zr.Close() }) + + return zr + } + + store := convertWith(0) + for _, f := range store.File { + if f.Method != zip.Store { + t.Errorf("level 0: %s stored with method %d, want Store", f.Name, f.Method) + } + if f.CompressedSize64 != f.UncompressedSize64 { + t.Errorf("level 0: %s is compressed (%d < %d)", f.Name, f.CompressedSize64, f.UncompressedSize64) + } + } + + deflate := convertWith(9) + for _, f := range deflate.File { + if f.Method != zip.Deflate { + t.Errorf("level 9: %s method %d, want Deflate", f.Name, f.Method) + } + } +} + func TestImageTransforms(t *testing.T) { conv := New(NewOptions()) diff --git a/cmd/cbconvert-gui/main.go b/cmd/cbconvert-gui/main.go index 855b67b..259153f 100644 --- a/cmd/cbconvert-gui/main.go +++ b/cmd/cbconvert-gui/main.go @@ -141,6 +141,7 @@ func options() cbconvert.Options { opts.NoConvert = iup.GetHandle("NoConvert").GetAttribute("VALUE") == "ON" opts.NoNonImage = iup.GetHandle("NoNonImage").GetAttribute("VALUE") == "ON" opts.Archive = strings.ToLower(iup.GetHandle("Archive").GetAttribute("VALUESTRING")) + opts.ZipLevel = zipLevel(iup.GetHandle("ZipLevel").GetAttribute("VALUESTRING")) opts.Format = strings.ToLower(iup.GetHandle("Format").GetAttribute("VALUESTRING")) opts.Width = iup.GetHandle("Width").GetInt("VALUE") opts.Height = iup.GetHandle("Height").GetInt("VALUE") @@ -246,6 +247,29 @@ func setActive() { } else { iup.GetHandle("VboxOutFile").SetAttribute("ACTIVE", "NO") } + + if opts.Archive == "zip" { + iup.GetHandle("VboxZipLevel").SetAttribute("ACTIVE", "YES") + } else { + iup.GetHandle("VboxZipLevel").SetAttribute("ACTIVE", "NO") + } +} + +// zipLevel maps the compression dropdown selection to Options.ZipLevel. +func zipLevel(value string) int { + switch value { + case "Default": + return -1 + case "Store (none)": + return 0 + default: + level, err := strconv.Atoi(value) + if err != nil { + return -1 + } + + return level + } } func setEffort(format string) { @@ -521,8 +545,32 @@ func tabs() iup.Ihandle { "VALUE": "1", "1": "ZIP", "2": "TAR", - }).SetHandle("Archive"), + }).SetHandle("Archive"). + SetCallback("VALUECHANGED_CB", iup.ValueChangedFunc(func(ih iup.Ihandle) int { + setActive() + + return iup.DEFAULT + })), ), + iup.Vbox( + iup.Label("Compression:"), + iup.List().SetAttributes(map[string]string{ + "DROPDOWN": "YES", + "VALUE": "1", + "1": "Default", + "2": "Store (none)", + "3": "1", + "4": "2", + "5": "3", + "6": "4", + "7": "5", + "8": "6", + "9": "7", + "10": "8", + "11": "9", + }).SetHandle("ZipLevel"). + SetAttribute("TIP", "ZIP compression: Store disables it, 1 is fastest, 9 is smallest"), + ).SetHandle("VboxZipLevel"), ).SetAttributes("NGAP=10"), iup.Space().SetAttribute("SIZE", "15"), iup.Vbox( diff --git a/cmd/cbconvert/main.go b/cmd/cbconvert/main.go index c007a4c..4a6af11 100644 --- a/cmd/cbconvert/main.go +++ b/cmd/cbconvert/main.go @@ -177,6 +177,7 @@ func parseFlags() (cbconvert.Options, []string) { convert.BoolVar(&opts.Fit, "fit", false, "Best fit for required width and height") convert.StringVar(&opts.Format, "format", "jpeg", "Image format, valid values are jpeg, png, tiff, bmp, webp, avif, jxl") convert.StringVar(&opts.Archive, "archive", "zip", "Archive format, valid values are zip, tar") + convert.IntVar(&opts.ZipLevel, "zip-level", -1, "ZIP compression level, 0 disables compression, 1-9 sets deflate level (1 fastest, 9 smallest), -1 uses the default") convert.IntVar(&opts.Quality, "quality", 75, "Image quality") convert.IntVar(&opts.Effort, "effort", -1, "Encoder speed/effort, format-specific (webp method 0-6, avif speed 0-10, jxl effort 1-10), -1 uses the format default") convert.BoolVar(&opts.Lossless, "lossless", false, "Lossless compression (webp, avif, jxl), ignores quality") @@ -235,7 +236,7 @@ func parseFlags() (cbconvert.Options, []string) { fmt.Fprintf(os.Stderr, "Usage: %s [] [file1 dir1 ... fileOrDirN]\n\n", filepath.Base(os.Args[0])) fmt.Fprintf(os.Stderr, "\nCommands:\n") fmt.Fprintf(os.Stderr, "\n convert\n \tConvert archive or document\n\n") - order := []string{"width", "height", "fit", "format", "archive", "quality", "effort", "lossless", "combine", "outfile", "filter", "no-cover", "no-rgb", + order := []string{"width", "height", "fit", "format", "archive", "zip-level", "quality", "effort", "lossless", "combine", "outfile", "filter", "no-cover", "no-rgb", "no-nonimage", "no-convert", "grayscale", "rotate", "brightness", "contrast", "suffix", "outdir", "size", "recursive", "quiet"} for _, name := range order { f := convert.Lookup(name)