From f289c9cd06f7d5ff99117c4030f0a879a3883c28 Mon Sep 17 00:00:00 2001 From: Milan Nikolic Date: Thu, 25 Jun 2026 10:59:12 +0200 Subject: [PATCH] Add profiles support to cli app --- cmd/cbconvert/main.go | 361 +++++++++++++++++++++++++++++++------ cmd/cbconvert/main_test.go | 160 ++++++++++++++++ 2 files changed, 466 insertions(+), 55 deletions(-) create mode 100644 cmd/cbconvert/main_test.go diff --git a/cmd/cbconvert/main.go b/cmd/cbconvert/main.go index 35f190a..9fae23f 100644 --- a/cmd/cbconvert/main.go +++ b/cmd/cbconvert/main.go @@ -2,13 +2,17 @@ package main import ( "bufio" + "errors" "flag" "fmt" "io" "os" "os/signal" "path/filepath" + "runtime" "runtime/debug" + "strconv" + "strings" "syscall" "github.com/gen2brain/cbconvert" @@ -188,63 +192,84 @@ func parseFlags() (cbconvert.Options, []string) { opts := cbconvert.Options{} 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.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") - 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") - convert.BoolVar(&opts.Combine, "combine", false, "Combine all inputs into a single archive") - convert.StringVar(&opts.OutFile, "outfile", "", "Output file name for --combine (default: first input + -combined)") - convert.IntVar(&opts.Filter, "filter", 2, "0=NearestNeighbor, 1=Box, 2=Linear, 3=MitchellNetravali, 4=CatmullRom, 6=Gaussian, 7=Lanczos") - convert.BoolVar(&opts.NoCover, "no-cover", false, "Do not convert the cover image") - convert.BoolVar(&opts.NoRGB, "no-rgb", false, "Do not convert images that have RGB colorspace") - convert.BoolVar(&opts.NoNonImage, "no-nonimage", false, "Remove non-image files from the archive") - convert.BoolVar(&opts.NoConvert, "no-convert", false, "Do not transform or convert images") - convert.BoolVar(&opts.Grayscale, "grayscale", false, "Convert images to grayscale (monochromatic)") - convert.IntVar(&opts.Rotate, "rotate", 0, "Rotate images, valid values are 0, 90, 180, 270") - convert.IntVar(&opts.Brightness, "brightness", 0, "Adjust the brightness of the images, must be in the range (-100, 100)") - convert.IntVar(&opts.Contrast, "contrast", 0, "Adjust the contrast of the images, must be in the range (-100, 100)") - convert.StringVar(&opts.Suffix, "suffix", "", "Add suffix to file basename") - convert.StringVar(&opts.OutDir, "outdir", ".", "Output directory") - convert.IntVar(&opts.Size, "size", 0, "Process only files larger than size (in MB)") - convert.BoolVar(&opts.Recursive, "recursive", false, "Process subdirectories recursively") - convert.BoolVar(&opts.Quiet, "quiet", false, "Hide console output") + convert.StringVar(&profile, "profile", "", profileUsage) + convert.IntVar(&opts.Width, "width", base.Width, "Image width") + convert.IntVar(&opts.Height, "height", base.Height, "Image height") + convert.BoolVar(&opts.Fit, "fit", base.Fit, "Best fit for required width and height") + convert.BoolVar(&opts.NoUpscale, "no-upscale", base.NoUpscale, "Do not upscale images already smaller than the requested width/height") + convert.IntVar(&opts.DPI, "dpi", base.DPI, "Document rendering resolution in DPI (PDF, EPUB, etc.), 0 uses the default (300)") + convert.StringVar(&opts.Format, "format", base.Format, "Image format, valid values are jpeg, png, tiff, bmp, webp, avif, jxl") + convert.StringVar(&opts.Archive, "archive", base.Archive, "Archive format, valid values are zip, tar") + 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.Quality, "quality", base.Quality, "Image 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.Lossless, "lossless", base.Lossless, "Lossless compression (webp, avif, jxl), ignores quality") + convert.BoolVar(&opts.Combine, "combine", base.Combine, "Combine all inputs into a single archive") + convert.StringVar(&opts.OutFile, "outfile", base.OutFile, "Output file name for --combine (default: first input + -combined)") + convert.IntVar(&opts.Filter, "filter", base.Filter, "0=NearestNeighbor, 1=Box, 2=Linear, 3=MitchellNetravali, 4=CatmullRom, 5=Gaussian, 6=Lanczos") + convert.BoolVar(&opts.NoCover, "no-cover", base.NoCover, "Do not convert the cover image") + convert.BoolVar(&opts.NoRGB, "no-rgb", base.NoRGB, "Do not convert images that have RGB colorspace") + convert.BoolVar(&opts.NoNonImage, "no-nonimage", base.NoNonImage, "Remove non-image files from the archive") + convert.BoolVar(&opts.NoConvert, "no-convert", base.NoConvert, "Do not transform or convert images") + convert.BoolVar(&opts.Grayscale, "grayscale", base.Grayscale, "Convert images to grayscale (monochromatic)") + convert.IntVar(&opts.Rotate, "rotate", base.Rotate, "Rotate images, valid values are 0, 90, 180, 270") + convert.IntVar(&opts.Brightness, "brightness", base.Brightness, "Adjust the brightness of the images, must be in the range (-100, 100)") + convert.IntVar(&opts.Contrast, "contrast", base.Contrast, "Adjust the contrast of the images, must be in the range (-100, 100)") + convert.StringVar(&opts.Suffix, "suffix", base.Suffix, "Add suffix to file basename") + convert.StringVar(&opts.OutDir, "outdir", base.OutDir, "Output directory") + convert.IntVar(&opts.Size, "size", base.Size, "Process only files larger than size (in MB)") + 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.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") - 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.BoolVar(&opts.Lossless, "lossless", false, "Lossless compression (webp, avif, jxl), ignores quality") - cover.IntVar(&opts.Filter, "filter", 2, "0=NearestNeighbor, 1=Box, 2=Linear, 3=MitchellNetravali, 4=CatmullRom, 6=Gaussian, 7=Lanczos") - cover.StringVar(&opts.OutDir, "outdir", ".", "Output directory") - cover.IntVar(&opts.Size, "size", 0, "Process only files larger than size (in MB)") - cover.BoolVar(&opts.Recursive, "recursive", false, "Process subdirectories recursively") - cover.BoolVar(&opts.Quiet, "quiet", false, "Hide console output") + cover.StringVar(&profile, "profile", "", profileUsage) + cover.IntVar(&opts.Width, "width", base.Width, "Image width") + cover.IntVar(&opts.Height, "height", base.Height, "Image height") + cover.BoolVar(&opts.Fit, "fit", base.Fit, "Best fit for required width and height") + cover.BoolVar(&opts.NoUpscale, "no-upscale", base.NoUpscale, "Do not upscale images already smaller than the requested width/height") + cover.IntVar(&opts.DPI, "dpi", base.DPI, "Document rendering resolution in DPI (PDF, EPUB, etc.), 0 uses the default (300)") + cover.StringVar(&opts.Format, "format", base.Format, "Image format, valid values are jpeg, png, tiff, bmp, webp, avif") + cover.IntVar(&opts.Quality, "quality", base.Quality, "Image 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.BoolVar(&opts.Lossless, "lossless", base.Lossless, "Lossless compression (webp, avif, jxl), ignores quality") + cover.IntVar(&opts.Filter, "filter", base.Filter, "0=NearestNeighbor, 1=Box, 2=Linear, 3=MitchellNetravali, 4=CatmullRom, 5=Gaussian, 6=Lanczos") + cover.StringVar(&opts.OutDir, "outdir", base.OutDir, "Output directory") + cover.IntVar(&opts.Size, "size", base.Size, "Process only files larger than size (in MB)") + 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.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") - thumbnail.StringVar(&opts.OutFile, "outfile", "", "Output file") - thumbnail.IntVar(&opts.Size, "size", 0, "Process only files larger than size (in MB)") - thumbnail.BoolVar(&opts.Recursive, "recursive", false, "Process subdirectories recursively") - thumbnail.BoolVar(&opts.Quiet, "quiet", false, "Hide console output") + thumbnail.StringVar(&profile, "profile", "", profileUsage) + thumbnail.IntVar(&opts.Width, "width", base.Width, "Image width") + thumbnail.IntVar(&opts.Height, "height", base.Height, "Image height") + thumbnail.BoolVar(&opts.Fit, "fit", base.Fit, "Best fit for required width and height") + thumbnail.BoolVar(&opts.NoUpscale, "no-upscale", base.NoUpscale, "Do not upscale images already smaller than the requested width/height") + thumbnail.IntVar(&opts.DPI, "dpi", base.DPI, "Document rendering resolution in DPI (PDF, EPUB, etc.), 0 uses the default (300)") + thumbnail.IntVar(&opts.Filter, "filter", base.Filter, "0=NearestNeighbor, 1=Box, 2=Linear, 3=MitchellNetravali, 4=CatmullRom, 5=Gaussian, 6=Lanczos") + thumbnail.StringVar(&opts.OutDir, "outdir", base.OutDir, "Output directory") + thumbnail.StringVar(&opts.OutFile, "outfile", base.OutFile, "Output file") + thumbnail.IntVar(&opts.Size, "size", base.Size, "Process only files larger than size (in MB)") + 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.BoolVar(&opts.Cover, "cover", false, "Print cover name") @@ -259,7 +284,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", "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"} for _, name := range order { 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, "\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 { 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", "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 { f := thumbnail.Lookup(name) fmt.Fprintf(os.Stderr, " --%s\n \t", f.Name) @@ -377,3 +402,229 @@ func lines(r io.Reader) []string { 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 + } +} diff --git a/cmd/cbconvert/main_test.go b/cmd/cbconvert/main_test.go new file mode 100644 index 0000000..147eb4d --- /dev/null +++ b/cmd/cbconvert/main_test.go @@ -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) + } +}