diff --git a/README.md b/README.md index 25d623b..33f38b5 100644 --- a/README.md +++ b/README.md @@ -70,6 +70,8 @@ Commands: Image height (default "0") --fit Best fit for required width and height (default "false") + --no-upscale + Do not upscale images already smaller than the requested width/height (default "false") --dpi Document rendering resolution in DPI (PDF, EPUB, etc.), 0 uses the default (300) (default "0") --format @@ -126,6 +128,8 @@ Commands: Image height (default "0") --fit Best fit for required width and height (default "false") + --no-upscale + Do not upscale images already smaller than the requested width/height (default "false") --dpi Document rendering resolution in DPI (PDF, EPUB, etc.), 0 uses the default (300) (default "0") --format @@ -156,6 +160,8 @@ Commands: Image height (default "0") --fit Best fit for required width and height (default "false") + --no-upscale + Do not upscale images already smaller than the requested width/height (default "false") --dpi Document rendering resolution in DPI (PDF, EPUB, etc.), 0 uses the default (300) (default "0") --filter diff --git a/cbconvert.go b/cbconvert.go index b099c2a..ad729d8 100644 --- a/cbconvert.go +++ b/cbconvert.go @@ -39,6 +39,8 @@ type Options struct { Height int // Best fit for required width and height Fit bool + // Do not upscale images already smaller than the requested width/height + NoUpscale bool // Document rendering resolution in DPI (PDF, EPUB, etc.); 0 uses the default DPI int // 0=NearestNeighbor, 1=Box, 2=Linear, 3=MitchellNetravali, 4=CatmullRom, 6=Gaussian, 7=Lanczos @@ -180,6 +182,7 @@ func (o Options) Args() []string { num("width", o.Width, def.Width) num("height", o.Height, def.Height) flag("fit", o.Fit) + flag("no-upscale", o.NoUpscale) num("dpi", o.DPI, def.DPI) str("format", o.Format, def.Format) str("archive", o.Archive, def.Archive) @@ -360,11 +363,7 @@ func (c *Converter) Cover(file File) error { } if c.Opts.Width > 0 || c.Opts.Height > 0 { - if c.Opts.Fit { - cover = fit(cover, c.Opts.Width, c.Opts.Height, filters[c.Opts.Filter]) - } else { - cover = resize(cover, c.Opts.Width, c.Opts.Height, filters[c.Opts.Filter]) - } + cover = c.resizeFit(cover) } ext := c.Opts.Format @@ -409,11 +408,7 @@ func (c *Converter) Thumbnail(file File) error { } if c.Opts.Width > 0 || c.Opts.Height > 0 { - if c.Opts.Fit { - cover = fit(cover, c.Opts.Width, c.Opts.Height, filters[c.Opts.Filter]) - } else { - cover = resize(cover, c.Opts.Width, c.Opts.Height, filters[c.Opts.Filter]) - } + cover = c.resizeFit(cover) } else { cover = resize(cover, 256, 0, filters[c.Opts.Filter]) } diff --git a/cbconvert_convert.go b/cbconvert_convert.go index aefbefe..0202363 100644 --- a/cbconvert_convert.go +++ b/cbconvert_convert.go @@ -326,11 +326,7 @@ func (c *Converter) imageTransform(img image.Image) image.Image { var i = img if c.Opts.Width > 0 || c.Opts.Height > 0 { - if c.Opts.Fit { - i = fit(i, c.Opts.Width, c.Opts.Height, filters[c.Opts.Filter]) - } else { - i = resize(i, c.Opts.Width, c.Opts.Height, filters[c.Opts.Filter]) - } + i = c.resizeFit(i) } if c.Opts.Rotate > 0 { diff --git a/cbconvert_image.go b/cbconvert_image.go index 94bda06..c4b51be 100644 --- a/cbconvert_image.go +++ b/cbconvert_image.go @@ -86,6 +86,26 @@ func fit(img image.Image, width, height int, filter transform.ResampleFilter) *i return resize(img, dstW, dstH, filter) } +// withinBounds reports whether img already fits within width by height; a zero dimension is unbounded. +func withinBounds(img image.Image, width, height int) bool { + b := img.Bounds() + + return (width == 0 || b.Dx() <= width) && (height == 0 || b.Dy() <= height) +} + +// resizeFit resizes img to the configured width/height, honoring Fit and NoUpscale. +func (c *Converter) resizeFit(img image.Image) image.Image { + if c.Opts.Fit { + return fit(img, c.Opts.Width, c.Opts.Height, filters[c.Opts.Filter]) + } + + if c.Opts.NoUpscale && withinBounds(img, c.Opts.Width, c.Opts.Height) { + return img + } + + return resize(img, c.Opts.Width, c.Opts.Height, filters[c.Opts.Filter]) +} + func rotate(img image.Image, angle float64) *image.RGBA { return transform.Rotate(img, angle, &transform.RotationOptions{ResizeBounds: true, Pivot: &image.Point{}}) } diff --git a/cbconvert_test.go b/cbconvert_test.go index 05593c0..d40bcb6 100644 --- a/cbconvert_test.go +++ b/cbconvert_test.go @@ -114,17 +114,68 @@ func TestArgs(t *testing.T) { opts.Effort = 4 opts.Lossless = true opts.Width = 1200 + opts.NoUpscale = true opts.DPI = 150 opts.Grayscale = true opts.OutDir = "/out" got := strings.Join(opts.Args(), " ") - want := "--width 1200 --dpi 150 --format webp --quality 90 --effort 4 --lossless --grayscale --outdir /out" + want := "--width 1200 --no-upscale --dpi 150 --format webp --quality 90 --effort 4 --lossless --grayscale --outdir /out" if got != want { t.Errorf("Args() = %q, want %q", got, want) } } +func TestNoUpscale(t *testing.T) { + tmpDir, err := os.MkdirTemp(os.TempDir(), "cbc") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(tmpDir) + + width := func(o Options) int { + conv := New(o) + 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) + } + } + + return firstPage(t, conv, filepath.Join(tmpDir, "test.cbz")).Bounds().Dx() + } + + base := NewOptions() + base.OutDir = tmpDir + orig := width(base) + + up := NewOptions() + up.OutDir = tmpDir + up.Width = orig * 2 + up.NoUpscale = true + if got := width(up); got != orig { + t.Errorf("NoUpscale should keep original width %d, got %d", orig, got) + } + + no := NewOptions() + no.OutDir = tmpDir + no.Width = orig * 2 + if got := width(no); got != orig*2 { + t.Errorf("without NoUpscale should upscale to %d, got %d", orig*2, got) + } + + down := NewOptions() + down.OutDir = tmpDir + down.Width = orig / 2 + down.NoUpscale = true + if got := width(down); got != orig/2 { + t.Errorf("NoUpscale should still downscale to %d, got %d", orig/2, got) + } +} + func TestConvertDPI(t *testing.T) { tmpDir, err := os.MkdirTemp(os.TempDir(), "cbc") if err != nil { diff --git a/cmd/cbconvert-gui/options.go b/cmd/cbconvert-gui/options.go index 090d5bb..ef3bf68 100644 --- a/cmd/cbconvert-gui/options.go +++ b/cmd/cbconvert-gui/options.go @@ -25,6 +25,7 @@ func options() cbconvert.Options { opts.Height = iup.GetHandle("Height").GetInt("VALUE") opts.DPI = dpiValue(iup.GetHandle("DPI").GetAttribute("VALUE")) opts.Fit = iup.GetHandle("Fit").GetAttribute("VALUE") == "ON" + opts.NoUpscale = iup.GetHandle("NoUpscale").GetAttribute("VALUE") == "ON" opts.Filter = iup.GetHandle("Filter").GetInt("VALUE") - 1 opts.Quality = iup.GetHandle("Quality").GetInt("VALUE") switch opts.Format { diff --git a/cmd/cbconvert-gui/settings.go b/cmd/cbconvert-gui/settings.go index 4437995..2505f91 100644 --- a/cmd/cbconvert-gui/settings.go +++ b/cmd/cbconvert-gui/settings.go @@ -39,6 +39,7 @@ var settings = []setting{ {"NoNonImage", kindBool, "OFF"}, {"Combine", kindBool, "OFF"}, {"Fit", kindBool, "OFF"}, + {"NoUpscale", kindBool, "OFF"}, {"Lossless", kindBool, "OFF"}, {"Grayscale", kindBool, "OFF"}, {"OutDir", kindStr, ""}, diff --git a/cmd/cbconvert-gui/widgets.go b/cmd/cbconvert-gui/widgets.go index 63ecde9..a0b4f08 100644 --- a/cmd/cbconvert-gui/widgets.go +++ b/cmd/cbconvert-gui/widgets.go @@ -359,6 +359,8 @@ func tabImage() iup.Ihandle { iup.Vbox( iup.Toggle(" Best Fit").SetHandle("Fit"). SetAttributes(`TIP="Best fit for required width and height"`), + iup.Toggle(" No Upscale").SetHandle("NoUpscale"). + SetAttribute("TIP", "Do not enlarge images already smaller than the requested size"), ), iup.Vbox( iup.Label("Resize Filter:"), diff --git a/cmd/cbconvert/go.mod b/cmd/cbconvert/go.mod index 27e60cc..30df8fc 100644 --- a/cmd/cbconvert/go.mod +++ b/cmd/cbconvert/go.mod @@ -5,6 +5,7 @@ go 1.26 require ( github.com/gen2brain/cbconvert v1.0.5-0.20260623161611-a5817c3ba5de github.com/schollz/progressbar/v3 v3.19.0 + golang.org/x/term v0.44.0 ) require ( @@ -52,7 +53,6 @@ require ( golang.org/x/net v0.56.0 // indirect golang.org/x/sync v0.21.0 // indirect golang.org/x/sys v0.46.0 // indirect - golang.org/x/term v0.44.0 // indirect golang.org/x/text v0.38.0 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect ) diff --git a/cmd/cbconvert/main.go b/cmd/cbconvert/main.go index 10d3fdb..35f190a 100644 --- a/cmd/cbconvert/main.go +++ b/cmd/cbconvert/main.go @@ -192,6 +192,7 @@ func parseFlags() (cbconvert.Options, []string) { convert.IntVar(&opts.Width, "width", 0, "Image width") convert.IntVar(&opts.Height, "height", 0, "Image height") convert.BoolVar(&opts.Fit, "fit", false, "Best fit for required width and height") + convert.BoolVar(&opts.NoUpscale, "no-upscale", false, "Do not upscale images already smaller than the requested width/height") convert.IntVar(&opts.DPI, "dpi", 0, "Document rendering resolution in DPI (PDF, EPUB, etc.), 0 uses the default (300)") 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") @@ -220,6 +221,7 @@ func parseFlags() (cbconvert.Options, []string) { cover.IntVar(&opts.Width, "width", 0, "Image width") cover.IntVar(&opts.Height, "height", 0, "Image height") cover.BoolVar(&opts.Fit, "fit", false, "Best fit for required width and height") + cover.BoolVar(&opts.NoUpscale, "no-upscale", false, "Do not upscale images already smaller than the requested width/height") cover.IntVar(&opts.DPI, "dpi", 0, "Document rendering resolution in DPI (PDF, EPUB, etc.), 0 uses the default (300)") cover.StringVar(&opts.Format, "format", "jpeg", "Image format, valid values are jpeg, png, tiff, bmp, webp, avif") cover.IntVar(&opts.Quality, "quality", 75, "Image quality") @@ -235,6 +237,7 @@ func parseFlags() (cbconvert.Options, []string) { thumbnail.IntVar(&opts.Width, "width", 0, "Image width") thumbnail.IntVar(&opts.Height, "height", 0, "Image height") thumbnail.BoolVar(&opts.Fit, "fit", false, "Best fit for required width and height") + thumbnail.BoolVar(&opts.NoUpscale, "no-upscale", false, "Do not upscale images already smaller than the requested width/height") thumbnail.IntVar(&opts.DPI, "dpi", 0, "Document rendering resolution in DPI (PDF, EPUB, etc.), 0 uses the default (300)") thumbnail.IntVar(&opts.Filter, "filter", 2, "0=NearestNeighbor, 1=Box, 2=Linear, 3=MitchellNetravali, 4=CatmullRom, 6=Gaussian, 7=Lanczos") thumbnail.StringVar(&opts.OutDir, "outdir", ".", "Output directory") @@ -256,7 +259,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", "dpi", "format", "archive", "zip-level", "quality", "effort", "lossless", "combine", "outfile", "filter", "no-cover", "no-rgb", + order := []string{"width", "height", "fit", "no-upscale", "dpi", "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) @@ -264,14 +267,14 @@ func parseFlags() (cbconvert.Options, []string) { fmt.Fprintf(os.Stderr, "%v (default %q)\n", f.Usage, f.DefValue) } fmt.Fprintf(os.Stderr, "\n cover\n \tExtract cover\n\n") - order = []string{"width", "height", "fit", "dpi", "format", "quality", "effort", "lossless", "filter", "outdir", "size", "recursive", "quiet"} + order = []string{"width", "height", "fit", "no-upscale", "dpi", "format", "quality", "effort", "lossless", "filter", "outdir", "size", "recursive", "quiet"} for _, name := range order { f := cover.Lookup(name) fmt.Fprintf(os.Stderr, " --%s\n \t", f.Name) fmt.Fprintf(os.Stderr, "%v (default %q)\n", f.Usage, f.DefValue) } fmt.Fprintf(os.Stderr, "\n thumbnail\n \tExtract cover thumbnail (freedesktop spec.)\n\n") - order = []string{"width", "height", "fit", "dpi", "filter", "outdir", "outfile", "size", "recursive", "quiet"} + order = []string{"width", "height", "fit", "no-upscale", "dpi", "filter", "outdir", "outfile", "size", "recursive", "quiet"} for _, name := range order { f := thumbnail.Lookup(name) fmt.Fprintf(os.Stderr, " --%s\n \t", f.Name)