Add profiles support to cli app

This commit is contained in:
Milan Nikolic
2026-06-25 10:59:12 +02:00
parent 0439a2edde
commit f289c9cd06
2 changed files with 466 additions and 55 deletions
+306 -55
View File
@@ -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
}
}
+160
View File
@@ -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)
}
}