mirror of
https://github.com/gen2brain/cbconvert
synced 2026-06-30 09:11:54 +02:00
Add profiles support to cli app
This commit is contained in:
+306
-55
@@ -2,13 +2,17 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bufio"
|
"bufio"
|
||||||
|
"errors"
|
||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"os"
|
"os"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
"runtime/debug"
|
"runtime/debug"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
"syscall"
|
"syscall"
|
||||||
|
|
||||||
"github.com/gen2brain/cbconvert"
|
"github.com/gen2brain/cbconvert"
|
||||||
@@ -188,63 +192,84 @@ func parseFlags() (cbconvert.Options, []string) {
|
|||||||
opts := cbconvert.Options{}
|
opts := cbconvert.Options{}
|
||||||
var args []string
|
var args []string
|
||||||
|
|
||||||
|
base := defaultOptions()
|
||||||
|
if len(os.Args) >= 2 {
|
||||||
|
switch os.Args[1] {
|
||||||
|
case "convert", "cover", "thumbnail":
|
||||||
|
if name := profileArg(os.Args[2:]); name != "" {
|
||||||
|
o, err := loadProfile(name)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Println(err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
base = o
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var profile string
|
||||||
|
const profileUsage = "Load a saved GUI profile as defaults; explicit flags still override"
|
||||||
|
|
||||||
convert := flag.NewFlagSet("convert", flag.ExitOnError)
|
convert := flag.NewFlagSet("convert", flag.ExitOnError)
|
||||||
convert.IntVar(&opts.Width, "width", 0, "Image width")
|
convert.StringVar(&profile, "profile", "", profileUsage)
|
||||||
convert.IntVar(&opts.Height, "height", 0, "Image height")
|
convert.IntVar(&opts.Width, "width", base.Width, "Image width")
|
||||||
convert.BoolVar(&opts.Fit, "fit", false, "Best fit for required width and height")
|
convert.IntVar(&opts.Height, "height", base.Height, "Image height")
|
||||||
convert.BoolVar(&opts.NoUpscale, "no-upscale", false, "Do not upscale images already smaller than the requested width/height")
|
convert.BoolVar(&opts.Fit, "fit", base.Fit, "Best fit for required width and height")
|
||||||
convert.IntVar(&opts.DPI, "dpi", 0, "Document rendering resolution in DPI (PDF, EPUB, etc.), 0 uses the default (300)")
|
convert.BoolVar(&opts.NoUpscale, "no-upscale", base.NoUpscale, "Do not upscale images already smaller than the requested width/height")
|
||||||
convert.StringVar(&opts.Format, "format", "jpeg", "Image format, valid values are jpeg, png, tiff, bmp, webp, avif, jxl")
|
convert.IntVar(&opts.DPI, "dpi", base.DPI, "Document rendering resolution in DPI (PDF, EPUB, etc.), 0 uses the default (300)")
|
||||||
convert.StringVar(&opts.Archive, "archive", "zip", "Archive format, valid values are zip, tar")
|
convert.StringVar(&opts.Format, "format", base.Format, "Image format, valid values are jpeg, png, tiff, bmp, webp, avif, jxl")
|
||||||
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.StringVar(&opts.Archive, "archive", base.Archive, "Archive format, valid values are zip, tar")
|
||||||
convert.IntVar(&opts.Quality, "quality", 75, "Image quality")
|
convert.IntVar(&opts.ZipLevel, "zip-level", base.ZipLevel, "ZIP compression level, 0 disables compression, 1-9 sets deflate level (1 fastest, 9 smallest), -1 uses the default")
|
||||||
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.IntVar(&opts.Quality, "quality", base.Quality, "Image quality")
|
||||||
convert.BoolVar(&opts.Lossless, "lossless", false, "Lossless compression (webp, avif, jxl), ignores quality")
|
convert.IntVar(&opts.Effort, "effort", base.Effort, "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.Combine, "combine", false, "Combine all inputs into a single archive")
|
convert.BoolVar(&opts.Lossless, "lossless", base.Lossless, "Lossless compression (webp, avif, jxl), ignores quality")
|
||||||
convert.StringVar(&opts.OutFile, "outfile", "", "Output file name for --combine (default: first input + -combined)")
|
convert.BoolVar(&opts.Combine, "combine", base.Combine, "Combine all inputs into a single archive")
|
||||||
convert.IntVar(&opts.Filter, "filter", 2, "0=NearestNeighbor, 1=Box, 2=Linear, 3=MitchellNetravali, 4=CatmullRom, 6=Gaussian, 7=Lanczos")
|
convert.StringVar(&opts.OutFile, "outfile", base.OutFile, "Output file name for --combine (default: first input + -combined)")
|
||||||
convert.BoolVar(&opts.NoCover, "no-cover", false, "Do not convert the cover image")
|
convert.IntVar(&opts.Filter, "filter", base.Filter, "0=NearestNeighbor, 1=Box, 2=Linear, 3=MitchellNetravali, 4=CatmullRom, 5=Gaussian, 6=Lanczos")
|
||||||
convert.BoolVar(&opts.NoRGB, "no-rgb", false, "Do not convert images that have RGB colorspace")
|
convert.BoolVar(&opts.NoCover, "no-cover", base.NoCover, "Do not convert the cover image")
|
||||||
convert.BoolVar(&opts.NoNonImage, "no-nonimage", false, "Remove non-image files from the archive")
|
convert.BoolVar(&opts.NoRGB, "no-rgb", base.NoRGB, "Do not convert images that have RGB colorspace")
|
||||||
convert.BoolVar(&opts.NoConvert, "no-convert", false, "Do not transform or convert images")
|
convert.BoolVar(&opts.NoNonImage, "no-nonimage", base.NoNonImage, "Remove non-image files from the archive")
|
||||||
convert.BoolVar(&opts.Grayscale, "grayscale", false, "Convert images to grayscale (monochromatic)")
|
convert.BoolVar(&opts.NoConvert, "no-convert", base.NoConvert, "Do not transform or convert images")
|
||||||
convert.IntVar(&opts.Rotate, "rotate", 0, "Rotate images, valid values are 0, 90, 180, 270")
|
convert.BoolVar(&opts.Grayscale, "grayscale", base.Grayscale, "Convert images to grayscale (monochromatic)")
|
||||||
convert.IntVar(&opts.Brightness, "brightness", 0, "Adjust the brightness of the images, must be in the range (-100, 100)")
|
convert.IntVar(&opts.Rotate, "rotate", base.Rotate, "Rotate images, valid values are 0, 90, 180, 270")
|
||||||
convert.IntVar(&opts.Contrast, "contrast", 0, "Adjust the contrast of the images, must be in the range (-100, 100)")
|
convert.IntVar(&opts.Brightness, "brightness", base.Brightness, "Adjust the brightness of the images, must be in the range (-100, 100)")
|
||||||
convert.StringVar(&opts.Suffix, "suffix", "", "Add suffix to file basename")
|
convert.IntVar(&opts.Contrast, "contrast", base.Contrast, "Adjust the contrast of the images, must be in the range (-100, 100)")
|
||||||
convert.StringVar(&opts.OutDir, "outdir", ".", "Output directory")
|
convert.StringVar(&opts.Suffix, "suffix", base.Suffix, "Add suffix to file basename")
|
||||||
convert.IntVar(&opts.Size, "size", 0, "Process only files larger than size (in MB)")
|
convert.StringVar(&opts.OutDir, "outdir", base.OutDir, "Output directory")
|
||||||
convert.BoolVar(&opts.Recursive, "recursive", false, "Process subdirectories recursively")
|
convert.IntVar(&opts.Size, "size", base.Size, "Process only files larger than size (in MB)")
|
||||||
convert.BoolVar(&opts.Quiet, "quiet", false, "Hide console output")
|
convert.BoolVar(&opts.Recursive, "recursive", base.Recursive, "Process subdirectories recursively")
|
||||||
|
convert.BoolVar(&opts.Quiet, "quiet", base.Quiet, "Hide console output")
|
||||||
|
|
||||||
cover := flag.NewFlagSet("cover", flag.ExitOnError)
|
cover := flag.NewFlagSet("cover", flag.ExitOnError)
|
||||||
cover.IntVar(&opts.Width, "width", 0, "Image width")
|
cover.StringVar(&profile, "profile", "", profileUsage)
|
||||||
cover.IntVar(&opts.Height, "height", 0, "Image height")
|
cover.IntVar(&opts.Width, "width", base.Width, "Image width")
|
||||||
cover.BoolVar(&opts.Fit, "fit", false, "Best fit for required width and height")
|
cover.IntVar(&opts.Height, "height", base.Height, "Image height")
|
||||||
cover.BoolVar(&opts.NoUpscale, "no-upscale", false, "Do not upscale images already smaller than the requested width/height")
|
cover.BoolVar(&opts.Fit, "fit", base.Fit, "Best fit for required width and height")
|
||||||
cover.IntVar(&opts.DPI, "dpi", 0, "Document rendering resolution in DPI (PDF, EPUB, etc.), 0 uses the default (300)")
|
cover.BoolVar(&opts.NoUpscale, "no-upscale", base.NoUpscale, "Do not upscale images already smaller than the requested width/height")
|
||||||
cover.StringVar(&opts.Format, "format", "jpeg", "Image format, valid values are jpeg, png, tiff, bmp, webp, avif")
|
cover.IntVar(&opts.DPI, "dpi", base.DPI, "Document rendering resolution in DPI (PDF, EPUB, etc.), 0 uses the default (300)")
|
||||||
cover.IntVar(&opts.Quality, "quality", 75, "Image quality")
|
cover.StringVar(&opts.Format, "format", base.Format, "Image format, valid values are jpeg, png, tiff, bmp, webp, avif")
|
||||||
cover.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")
|
cover.IntVar(&opts.Quality, "quality", base.Quality, "Image quality")
|
||||||
cover.BoolVar(&opts.Lossless, "lossless", false, "Lossless compression (webp, avif, jxl), ignores quality")
|
cover.IntVar(&opts.Effort, "effort", base.Effort, "Encoder speed/effort, format-specific (webp method 0-6, avif speed 0-10, jxl effort 1-10), -1 uses the format default")
|
||||||
cover.IntVar(&opts.Filter, "filter", 2, "0=NearestNeighbor, 1=Box, 2=Linear, 3=MitchellNetravali, 4=CatmullRom, 6=Gaussian, 7=Lanczos")
|
cover.BoolVar(&opts.Lossless, "lossless", base.Lossless, "Lossless compression (webp, avif, jxl), ignores quality")
|
||||||
cover.StringVar(&opts.OutDir, "outdir", ".", "Output directory")
|
cover.IntVar(&opts.Filter, "filter", base.Filter, "0=NearestNeighbor, 1=Box, 2=Linear, 3=MitchellNetravali, 4=CatmullRom, 5=Gaussian, 6=Lanczos")
|
||||||
cover.IntVar(&opts.Size, "size", 0, "Process only files larger than size (in MB)")
|
cover.StringVar(&opts.OutDir, "outdir", base.OutDir, "Output directory")
|
||||||
cover.BoolVar(&opts.Recursive, "recursive", false, "Process subdirectories recursively")
|
cover.IntVar(&opts.Size, "size", base.Size, "Process only files larger than size (in MB)")
|
||||||
cover.BoolVar(&opts.Quiet, "quiet", false, "Hide console output")
|
cover.BoolVar(&opts.Recursive, "recursive", base.Recursive, "Process subdirectories recursively")
|
||||||
|
cover.BoolVar(&opts.Quiet, "quiet", base.Quiet, "Hide console output")
|
||||||
|
|
||||||
thumbnail := flag.NewFlagSet("thumbnail", flag.ExitOnError)
|
thumbnail := flag.NewFlagSet("thumbnail", flag.ExitOnError)
|
||||||
thumbnail.IntVar(&opts.Width, "width", 0, "Image width")
|
thumbnail.StringVar(&profile, "profile", "", profileUsage)
|
||||||
thumbnail.IntVar(&opts.Height, "height", 0, "Image height")
|
thumbnail.IntVar(&opts.Width, "width", base.Width, "Image width")
|
||||||
thumbnail.BoolVar(&opts.Fit, "fit", false, "Best fit for required width and height")
|
thumbnail.IntVar(&opts.Height, "height", base.Height, "Image height")
|
||||||
thumbnail.BoolVar(&opts.NoUpscale, "no-upscale", false, "Do not upscale images already smaller than the requested width/height")
|
thumbnail.BoolVar(&opts.Fit, "fit", base.Fit, "Best fit for required width and height")
|
||||||
thumbnail.IntVar(&opts.DPI, "dpi", 0, "Document rendering resolution in DPI (PDF, EPUB, etc.), 0 uses the default (300)")
|
thumbnail.BoolVar(&opts.NoUpscale, "no-upscale", base.NoUpscale, "Do not upscale images already smaller than the requested width/height")
|
||||||
thumbnail.IntVar(&opts.Filter, "filter", 2, "0=NearestNeighbor, 1=Box, 2=Linear, 3=MitchellNetravali, 4=CatmullRom, 6=Gaussian, 7=Lanczos")
|
thumbnail.IntVar(&opts.DPI, "dpi", base.DPI, "Document rendering resolution in DPI (PDF, EPUB, etc.), 0 uses the default (300)")
|
||||||
thumbnail.StringVar(&opts.OutDir, "outdir", ".", "Output directory")
|
thumbnail.IntVar(&opts.Filter, "filter", base.Filter, "0=NearestNeighbor, 1=Box, 2=Linear, 3=MitchellNetravali, 4=CatmullRom, 5=Gaussian, 6=Lanczos")
|
||||||
thumbnail.StringVar(&opts.OutFile, "outfile", "", "Output file")
|
thumbnail.StringVar(&opts.OutDir, "outdir", base.OutDir, "Output directory")
|
||||||
thumbnail.IntVar(&opts.Size, "size", 0, "Process only files larger than size (in MB)")
|
thumbnail.StringVar(&opts.OutFile, "outfile", base.OutFile, "Output file")
|
||||||
thumbnail.BoolVar(&opts.Recursive, "recursive", false, "Process subdirectories recursively")
|
thumbnail.IntVar(&opts.Size, "size", base.Size, "Process only files larger than size (in MB)")
|
||||||
thumbnail.BoolVar(&opts.Quiet, "quiet", false, "Hide console output")
|
thumbnail.BoolVar(&opts.Recursive, "recursive", base.Recursive, "Process subdirectories recursively")
|
||||||
|
thumbnail.BoolVar(&opts.Quiet, "quiet", base.Quiet, "Hide console output")
|
||||||
|
|
||||||
meta := flag.NewFlagSet("meta", flag.ExitOnError)
|
meta := flag.NewFlagSet("meta", flag.ExitOnError)
|
||||||
meta.BoolVar(&opts.Cover, "cover", false, "Print cover name")
|
meta.BoolVar(&opts.Cover, "cover", false, "Print cover name")
|
||||||
@@ -259,7 +284,7 @@ func parseFlags() (cbconvert.Options, []string) {
|
|||||||
fmt.Fprintf(os.Stderr, "Usage: %s <command> [<flags>] [file1 dir1 ... fileOrDirN]\n\n", filepath.Base(os.Args[0]))
|
fmt.Fprintf(os.Stderr, "Usage: %s <command> [<flags>] [file1 dir1 ... fileOrDirN]\n\n", filepath.Base(os.Args[0]))
|
||||||
fmt.Fprintf(os.Stderr, "\nCommands:\n")
|
fmt.Fprintf(os.Stderr, "\nCommands:\n")
|
||||||
fmt.Fprintf(os.Stderr, "\n convert\n \tConvert archive or document\n\n")
|
fmt.Fprintf(os.Stderr, "\n convert\n \tConvert archive or document\n\n")
|
||||||
order := []string{"width", "height", "fit", "no-upscale", "dpi", "format", "archive", "zip-level", "quality", "effort", "lossless", "combine", "outfile", "filter", "no-cover", "no-rgb",
|
order := []string{"profile", "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"}
|
"no-nonimage", "no-convert", "grayscale", "rotate", "brightness", "contrast", "suffix", "outdir", "size", "recursive", "quiet"}
|
||||||
for _, name := range order {
|
for _, name := range order {
|
||||||
f := convert.Lookup(name)
|
f := convert.Lookup(name)
|
||||||
@@ -267,14 +292,14 @@ func parseFlags() (cbconvert.Options, []string) {
|
|||||||
fmt.Fprintf(os.Stderr, "%v (default %q)\n", f.Usage, f.DefValue)
|
fmt.Fprintf(os.Stderr, "%v (default %q)\n", f.Usage, f.DefValue)
|
||||||
}
|
}
|
||||||
fmt.Fprintf(os.Stderr, "\n cover\n \tExtract cover\n\n")
|
fmt.Fprintf(os.Stderr, "\n cover\n \tExtract cover\n\n")
|
||||||
order = []string{"width", "height", "fit", "no-upscale", "dpi", "format", "quality", "effort", "lossless", "filter", "outdir", "size", "recursive", "quiet"}
|
order = []string{"profile", "width", "height", "fit", "no-upscale", "dpi", "format", "quality", "effort", "lossless", "filter", "outdir", "size", "recursive", "quiet"}
|
||||||
for _, name := range order {
|
for _, name := range order {
|
||||||
f := cover.Lookup(name)
|
f := cover.Lookup(name)
|
||||||
fmt.Fprintf(os.Stderr, " --%s\n \t", f.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, "%v (default %q)\n", f.Usage, f.DefValue)
|
||||||
}
|
}
|
||||||
fmt.Fprintf(os.Stderr, "\n thumbnail\n \tExtract cover thumbnail (freedesktop spec.)\n\n")
|
fmt.Fprintf(os.Stderr, "\n thumbnail\n \tExtract cover thumbnail (freedesktop spec.)\n\n")
|
||||||
order = []string{"width", "height", "fit", "no-upscale", "dpi", "filter", "outdir", "outfile", "size", "recursive", "quiet"}
|
order = []string{"profile", "width", "height", "fit", "no-upscale", "dpi", "filter", "outdir", "outfile", "size", "recursive", "quiet"}
|
||||||
for _, name := range order {
|
for _, name := range order {
|
||||||
f := thumbnail.Lookup(name)
|
f := thumbnail.Lookup(name)
|
||||||
fmt.Fprintf(os.Stderr, " --%s\n \t", f.Name)
|
fmt.Fprintf(os.Stderr, " --%s\n \t", f.Name)
|
||||||
@@ -377,3 +402,229 @@ func lines(r io.Reader) []string {
|
|||||||
|
|
||||||
return data
|
return data
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// configPath returns the IupConfig file the GUI writes, matching IUP's per-platform location for APPNAME "cbconvert".
|
||||||
|
func configPath() (string, error) {
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
dir := os.Getenv("LocalAppData")
|
||||||
|
if dir == "" {
|
||||||
|
return "", errors.New("configPath: LocalAppData is not set")
|
||||||
|
}
|
||||||
|
|
||||||
|
return filepath.Join(dir, "cbconvert", "config.cfg"), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
dir, err := os.UserConfigDir()
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("configPath: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return filepath.Join(dir, "cbconvert", "config"), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseINI reads a simple INI file into section -> key -> value.
|
||||||
|
func parseINI(path string) (map[string]map[string]string, error) {
|
||||||
|
f, err := os.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
|
||||||
|
sections := make(map[string]map[string]string)
|
||||||
|
var cur map[string]string
|
||||||
|
|
||||||
|
sc := bufio.NewScanner(f)
|
||||||
|
for sc.Scan() {
|
||||||
|
line := strings.TrimSpace(sc.Text())
|
||||||
|
if line == "" || strings.HasPrefix(line, ";") || strings.HasPrefix(line, "#") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.HasPrefix(line, "[") && strings.HasSuffix(line, "]") {
|
||||||
|
cur = make(map[string]string)
|
||||||
|
sections[line[1:len(line)-1]] = cur
|
||||||
|
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if cur == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if k, v, ok := strings.Cut(line, "="); ok {
|
||||||
|
cur[strings.TrimSpace(k)] = strings.TrimSpace(v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := sc.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return sections, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// defaultOptions returns the convert defaults used when no profile is loaded.
|
||||||
|
func defaultOptions() cbconvert.Options {
|
||||||
|
o := cbconvert.NewOptions()
|
||||||
|
o.OutDir = "."
|
||||||
|
|
||||||
|
return o
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadProfile reads the named GUI profile and translates its control values into Options.
|
||||||
|
func loadProfile(name string) (cbconvert.Options, error) {
|
||||||
|
o := defaultOptions()
|
||||||
|
|
||||||
|
path, err := configPath()
|
||||||
|
if err != nil {
|
||||||
|
return o, fmt.Errorf("loadProfile: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ini, err := parseINI(path)
|
||||||
|
if err != nil {
|
||||||
|
return o, fmt.Errorf("loadProfile: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
sec, ok := ini["Profile:"+name]
|
||||||
|
if !ok {
|
||||||
|
return o, fmt.Errorf("loadProfile: profile %q not found in %s%s", name, path, knownProfiles(ini))
|
||||||
|
}
|
||||||
|
|
||||||
|
str := func(key string, set func(string)) {
|
||||||
|
if v, ok := sec[key]; ok {
|
||||||
|
set(v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
boolean := func(key string, set func(bool)) {
|
||||||
|
if v, ok := sec[key]; ok {
|
||||||
|
set(v == "1")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
integer := func(key string, set func(int)) {
|
||||||
|
if v, ok := sec[key]; ok {
|
||||||
|
if n, err := strconv.Atoi(v); err == nil {
|
||||||
|
set(n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
integer("Width", func(n int) { o.Width = n })
|
||||||
|
integer("Height", func(n int) { o.Height = n })
|
||||||
|
boolean("Fit", func(b bool) { o.Fit = b })
|
||||||
|
boolean("NoUpscale", func(b bool) { o.NoUpscale = b })
|
||||||
|
str("DPI", func(v string) { o.DPI = dpiFromString(v) })
|
||||||
|
str("Format", func(v string) { o.Format = formatFromIndex(v) })
|
||||||
|
str("Archive", func(v string) { o.Archive = archiveFromIndex(v) })
|
||||||
|
str("ZipLevel", func(v string) { o.ZipLevel = zipLevelFromIndex(v) })
|
||||||
|
integer("Quality", func(n int) { o.Quality = n })
|
||||||
|
integer("Effort", func(n int) { o.Effort = n })
|
||||||
|
boolean("Lossless", func(b bool) { o.Lossless = b })
|
||||||
|
boolean("Combine", func(b bool) { o.Combine = b })
|
||||||
|
integer("Filter", func(n int) { o.Filter = n - 1 })
|
||||||
|
boolean("NoCover", func(b bool) { o.NoCover = b })
|
||||||
|
boolean("NoRGB", func(b bool) { o.NoRGB = b })
|
||||||
|
boolean("NoNonImage", func(b bool) { o.NoNonImage = b })
|
||||||
|
boolean("NoConvert", func(b bool) { o.NoConvert = b })
|
||||||
|
boolean("Grayscale", func(b bool) { o.Grayscale = b })
|
||||||
|
str("Rotate", func(v string) { o.Rotate = rotateFromIndex(v) })
|
||||||
|
integer("Brightness", func(n int) { o.Brightness = n })
|
||||||
|
integer("Contrast", func(n int) { o.Contrast = n })
|
||||||
|
str("Suffix", func(v string) { o.Suffix = v })
|
||||||
|
str("OutDir", func(v string) { o.OutDir = v })
|
||||||
|
integer("Size", func(n int) { o.Size = n })
|
||||||
|
boolean("Recursive", func(b bool) { o.Recursive = b })
|
||||||
|
|
||||||
|
// Effort is format-specific in the GUI: only webp/avif/jxl use the slider, others fall back to the format default.
|
||||||
|
switch o.Format {
|
||||||
|
case "webp", "avif", "jxl":
|
||||||
|
default:
|
||||||
|
o.Effort = -1
|
||||||
|
}
|
||||||
|
|
||||||
|
return o, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// knownProfiles lists the profile names from the config, for a helpful "not found" message.
|
||||||
|
func knownProfiles(ini map[string]map[string]string) string {
|
||||||
|
if p, ok := ini["Profiles"]; ok {
|
||||||
|
if names := p["Names"]; names != "" {
|
||||||
|
return "\navailable profiles: " + strings.ReplaceAll(names, ";", ", ")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// profileArg extracts the --profile value from args, since it must be known before flag defaults are built.
|
||||||
|
func profileArg(args []string) string {
|
||||||
|
for i := 0; i < len(args); i++ {
|
||||||
|
if args[i] == "--profile" || args[i] == "-profile" {
|
||||||
|
if i+1 < len(args) {
|
||||||
|
return args[i+1]
|
||||||
|
}
|
||||||
|
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, pfx := range []string{"--profile=", "-profile="} {
|
||||||
|
if v, ok := strings.CutPrefix(args[i], pfx); ok {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// The index translations below mirror the GUI dropdown encodings stored in the profile.
|
||||||
|
|
||||||
|
func dpiFromString(s string) int {
|
||||||
|
n, err := strconv.Atoi(strings.TrimSpace(s))
|
||||||
|
if err != nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
var profileFormats = []string{"jpeg", "png", "tiff", "bmp", "webp", "avif", "jxl"}
|
||||||
|
|
||||||
|
func formatFromIndex(s string) string {
|
||||||
|
if i, _ := strconv.Atoi(s); i >= 1 && i <= len(profileFormats) {
|
||||||
|
return profileFormats[i-1]
|
||||||
|
}
|
||||||
|
|
||||||
|
return "jpeg"
|
||||||
|
}
|
||||||
|
|
||||||
|
func archiveFromIndex(s string) string {
|
||||||
|
if s == "2" {
|
||||||
|
return "tar"
|
||||||
|
}
|
||||||
|
|
||||||
|
return "zip"
|
||||||
|
}
|
||||||
|
|
||||||
|
func zipLevelFromIndex(s string) int {
|
||||||
|
switch i, _ := strconv.Atoi(s); i {
|
||||||
|
case 1:
|
||||||
|
return -1
|
||||||
|
case 2:
|
||||||
|
return 0
|
||||||
|
default:
|
||||||
|
return i - 2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func rotateFromIndex(s string) int {
|
||||||
|
switch s {
|
||||||
|
case "2":
|
||||||
|
return 90
|
||||||
|
case "3":
|
||||||
|
return 180
|
||||||
|
case "4":
|
||||||
|
return 270
|
||||||
|
default:
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,160 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestProfileArg(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
args []string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{[]string{"--profile", "webp", "a.cbz"}, "webp"},
|
||||||
|
{[]string{"--profile=webp", "a.cbz"}, "webp"},
|
||||||
|
{[]string{"-profile", "webp"}, "webp"},
|
||||||
|
{[]string{"-profile=webp"}, "webp"},
|
||||||
|
{[]string{"--width", "800", "--profile", "x", "a.cbz"}, "x"},
|
||||||
|
{[]string{"--width", "800", "a.cbz"}, ""},
|
||||||
|
{[]string{"--profile"}, ""},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, c := range cases {
|
||||||
|
if got := profileArg(c.args); got != c.want {
|
||||||
|
t.Errorf("profileArg(%v) = %q, want %q", c.args, got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIndexTranslations(t *testing.T) {
|
||||||
|
if got := formatFromIndex("5"); got != "webp" {
|
||||||
|
t.Errorf("formatFromIndex(5) = %q, want webp", got)
|
||||||
|
}
|
||||||
|
if got := formatFromIndex("1"); got != "jpeg" {
|
||||||
|
t.Errorf("formatFromIndex(1) = %q, want jpeg", got)
|
||||||
|
}
|
||||||
|
if got := formatFromIndex("99"); got != "jpeg" {
|
||||||
|
t.Errorf("formatFromIndex(99) = %q, want jpeg fallback", got)
|
||||||
|
}
|
||||||
|
if got := archiveFromIndex("2"); got != "tar" {
|
||||||
|
t.Errorf("archiveFromIndex(2) = %q, want tar", got)
|
||||||
|
}
|
||||||
|
if got := archiveFromIndex("1"); got != "zip" {
|
||||||
|
t.Errorf("archiveFromIndex(1) = %q, want zip", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
zip := map[string]int{"1": -1, "2": 0, "3": 1, "11": 9}
|
||||||
|
for in, want := range zip {
|
||||||
|
if got := zipLevelFromIndex(in); got != want {
|
||||||
|
t.Errorf("zipLevelFromIndex(%s) = %d, want %d", in, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rot := map[string]int{"1": 0, "2": 90, "3": 180, "4": 270}
|
||||||
|
for in, want := range rot {
|
||||||
|
if got := rotateFromIndex(in); got != want {
|
||||||
|
t.Errorf("rotateFromIndex(%s) = %d, want %d", in, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if got := dpiFromString("Default"); got != 0 {
|
||||||
|
t.Errorf("dpiFromString(Default) = %d, want 0", got)
|
||||||
|
}
|
||||||
|
if got := dpiFromString("150"); got != 150 {
|
||||||
|
t.Errorf("dpiFromString(150) = %d, want 150", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseINI(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "config")
|
||||||
|
data := "[Profiles]\nNames=Default;webp\n\n[Profile:webp]\nFormat=5\nQuality=90\n"
|
||||||
|
if err := os.WriteFile(path, []byte(data), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ini, err := parseINI(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if ini["Profile:webp"]["Format"] != "5" {
|
||||||
|
t.Errorf("Format = %q, want 5", ini["Profile:webp"]["Format"])
|
||||||
|
}
|
||||||
|
if ini["Profiles"]["Names"] != "Default;webp" {
|
||||||
|
t.Errorf("Names = %q", ini["Profiles"]["Names"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadProfile(t *testing.T) {
|
||||||
|
if runtime.GOOS != "linux" {
|
||||||
|
t.Skip("config path override via XDG_CONFIG_HOME is Linux-specific")
|
||||||
|
}
|
||||||
|
|
||||||
|
dir := t.TempDir()
|
||||||
|
if err := os.MkdirAll(filepath.Join(dir, "cbconvert"), 0755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
data := "[Profile:webp]\nFormat=5\nQuality=90\nEffort=4\nArchive=2\nWidth=800\nFit=1\nFilter=7\nRotate=2\n"
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, "cbconvert", "config"), []byte(data), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Setenv("XDG_CONFIG_HOME", dir)
|
||||||
|
|
||||||
|
o, err := loadProfile("webp")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if o.Format != "webp" {
|
||||||
|
t.Errorf("Format = %q, want webp", o.Format)
|
||||||
|
}
|
||||||
|
if o.Quality != 90 {
|
||||||
|
t.Errorf("Quality = %d, want 90", o.Quality)
|
||||||
|
}
|
||||||
|
if o.Effort != 4 {
|
||||||
|
t.Errorf("Effort = %d, want 4 (webp keeps the slider value)", o.Effort)
|
||||||
|
}
|
||||||
|
if o.Archive != "tar" {
|
||||||
|
t.Errorf("Archive = %q, want tar", o.Archive)
|
||||||
|
}
|
||||||
|
if o.Width != 800 || !o.Fit {
|
||||||
|
t.Errorf("Width/Fit = %d/%v, want 800/true", o.Width, o.Fit)
|
||||||
|
}
|
||||||
|
if o.Filter != 6 {
|
||||||
|
t.Errorf("Filter = %d, want 6 (GUI index 7 - 1)", o.Filter)
|
||||||
|
}
|
||||||
|
if o.Rotate != 90 {
|
||||||
|
t.Errorf("Rotate = %d, want 90", o.Rotate)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := loadProfile("missing"); err == nil {
|
||||||
|
t.Error("loadProfile(missing) should error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadProfileEffortGate(t *testing.T) {
|
||||||
|
if runtime.GOOS != "linux" {
|
||||||
|
t.Skip("config path override via XDG_CONFIG_HOME is Linux-specific")
|
||||||
|
}
|
||||||
|
|
||||||
|
dir := t.TempDir()
|
||||||
|
if err := os.MkdirAll(filepath.Join(dir, "cbconvert"), 0755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// Format=1 (jpeg) with a stored Effort must collapse to -1, mirroring the GUI.
|
||||||
|
data := "[Profile:jpeg]\nFormat=1\nEffort=4\n"
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, "cbconvert", "config"), []byte(data), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Setenv("XDG_CONFIG_HOME", dir)
|
||||||
|
|
||||||
|
o, err := loadProfile("jpeg")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if o.Effort != -1 {
|
||||||
|
t.Errorf("Effort = %d, want -1 for non-effort format", o.Effort)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user