5 Commits
Author SHA1 Message Date
Milan Nikolic c4f236bdf9 Add Command button, issue #33 2026-06-24 13:16:09 +02:00
Milan Nikolic 23ac71ee0c Add settings profiles, issue #34 and issue #58 2026-06-24 11:20:12 +02:00
Milan Nikolic 3f0ae41456 Add ZipLevel, issue #48 2026-06-24 07:55:16 +02:00
Milan Nikolic 04a047aa2e Add more tests 2026-06-24 07:10:02 +02:00
Milan Nikolic 1284b9ded7 Fix outdir and recursive, issue #41 2026-06-24 06:42:48 +02:00
5 changed files with 1112 additions and 65 deletions
+92 -11
View File
@@ -23,6 +23,8 @@ type Options struct {
Format string Format string
// Archive format, valid values are zip, tar // Archive format, valid values are zip, tar
Archive string Archive string
// ZIP compression level: -1 default, 0 store (no compression), 1-9 deflate (1 fastest, 9 smallest)
ZipLevel int
// JPEG image quality // JPEG image quality
Quality int Quality int
// Encoder speed/effort, format-specific: webp method 0-6, avif speed 0-10, jxl effort 1-10; -1 uses the format default // Encoder speed/effort, format-specific: webp method 0-6, avif speed 0-10, jxl effort 1-10; -1 uses the format default
@@ -93,6 +95,8 @@ type Converter struct {
Workdir string Workdir string
// Page name prefix, set per input when combining // Page name prefix, set per input when combining
prefix string prefix string
// Input root for the current file, used to build recursive output paths
root string
// Number of files // Number of files
Nfiles int Nfiles int
// Index of the current file // Index of the current file
@@ -115,6 +119,7 @@ type Converter struct {
type File struct { type File struct {
Name string Name string
Path string Path string
Root string
Stat os.FileInfo Stat os.FileInfo
SizeHuman string SizeHuman string
} }
@@ -134,6 +139,7 @@ func NewOptions() Options {
o.Archive = "zip" o.Archive = "zip"
o.Quality = 75 o.Quality = 75
o.Effort = -1 o.Effort = -1
o.ZipLevel = -1
o.Filter = 2 o.Filter = 2
return o return o
@@ -147,6 +153,56 @@ func New(o Options) *Converter {
return c return c
} }
// Args returns the non-default options as cbconvert convert command-line flags.
func (o Options) Args() []string {
def := NewOptions()
var args []string
str := func(name, val, dflt string) {
if val != dflt {
args = append(args, "--"+name, val)
}
}
num := func(name string, val, dflt int) {
if val != dflt {
args = append(args, "--"+name, strconv.Itoa(val))
}
}
flag := func(name string, val bool) {
if val {
args = append(args, "--"+name)
}
}
num("width", o.Width, def.Width)
num("height", o.Height, def.Height)
flag("fit", o.Fit)
str("format", o.Format, def.Format)
str("archive", o.Archive, def.Archive)
num("zip-level", o.ZipLevel, def.ZipLevel)
num("quality", o.Quality, def.Quality)
num("effort", o.Effort, def.Effort)
flag("lossless", o.Lossless)
flag("combine", o.Combine)
str("outfile", o.OutFile, def.OutFile)
num("filter", o.Filter, def.Filter)
flag("no-cover", o.NoCover)
flag("no-rgb", o.NoRGB)
flag("no-nonimage", o.NoNonImage)
flag("no-convert", o.NoConvert)
str("suffix", o.Suffix, def.Suffix)
flag("grayscale", o.Grayscale)
num("rotate", o.Rotate, def.Rotate)
num("brightness", o.Brightness, def.Brightness)
num("contrast", o.Contrast, def.Contrast)
flag("recursive", o.Recursive)
str("outdir", o.OutDir, def.OutDir)
num("size", o.Size, def.Size)
return args
}
// Cancel cancels the operation. // Cancel cancels the operation.
func (c *Converter) Cancel() { func (c *Converter) Cancel() {
if c.OnCancel != nil { if c.OnCancel != nil {
@@ -157,11 +213,13 @@ func (c *Converter) Cancel() {
// Files returns list of found comic files. // Files returns list of found comic files.
func (c *Converter) Files(args []string) ([]File, error) { func (c *Converter) Files(args []string) ([]File, error) {
var files []File var files []File
var root string
toFile := func(fp string, f os.FileInfo) File { toFile := func(fp string, f os.FileInfo) File {
var file File var file File
file.Name = filepath.Base(fp) file.Name = filepath.Base(fp)
file.Path = fp file.Path = fp
file.Root = root
file.Stat = f file.Stat = f
file.SizeHuman = humanize.IBytes(uint64(f.Size())) file.SizeHuman = humanize.IBytes(uint64(f.Size()))
return file return file
@@ -214,12 +272,14 @@ func (c *Converter) Files(args []string) ([]File, error) {
} }
if !stat.IsDir() { if !stat.IsDir() {
root = filepath.Dir(path)
if isArchive(path) || isDocument(path) { if isArchive(path) || isDocument(path) {
if isSize(int64(c.Opts.Size), stat.Size()) { if isSize(int64(c.Opts.Size), stat.Size()) {
files = append(files, toFile(path, stat)) files = append(files, toFile(path, stat))
} }
} }
} else { } else {
root = path
if c.Opts.Recursive { if c.Opts.Recursive {
if err := filepath.Walk(path, walkFiles); err != nil { if err := filepath.Walk(path, walkFiles); err != nil {
return files, fmt.Errorf("%s: %w", arg, err) return files, fmt.Errorf("%s: %w", arg, err)
@@ -261,9 +321,26 @@ func (c *Converter) Files(args []string) ([]File, error) {
return files, nil return files, nil
} }
// recursiveDir mirrors the source path under OutDir, relative to the input root.
func (c *Converter) recursiveDir(fileName string) string {
dir := filepath.Dir(fileName)
if c.root != "" {
if rel, err := filepath.Rel(c.root, dir); err == nil {
return filepath.Join(c.Opts.OutDir, rel)
}
}
dir = strings.TrimPrefix(dir[len(filepath.VolumeName(dir)):], string(os.PathSeparator))
return filepath.Join(c.Opts.OutDir, dir)
}
// Cover extracts cover. // Cover extracts cover.
func (c *Converter) Cover(fileName string, fileInfo os.FileInfo) error { func (c *Converter) Cover(file File) error {
c.CurrFile++ c.CurrFile++
c.root = file.Root
fileName, fileInfo := file.Path, file.Stat
cover, err := c.coverImage(fileName, fileInfo) cover, err := c.coverImage(fileName, fileInfo)
if err != nil { if err != nil {
@@ -285,13 +362,12 @@ func (c *Converter) Cover(fileName string, fileInfo os.FileInfo) error {
var fName string var fName string
if c.Opts.Recursive { if c.Opts.Recursive {
fDir := strings.Split(filepath.Dir(fileName), string(os.PathSeparator))[1:] outDir := c.recursiveDir(fileName)
err := os.MkdirAll(filepath.Join(c.Opts.OutDir, filepath.Join(fDir...)), 0755) if err := os.MkdirAll(outDir, 0755); err != nil {
if err != nil {
return fmt.Errorf("%s: %w", fileName, err) return fmt.Errorf("%s: %w", fileName, err)
} }
fName = filepath.Join(c.Opts.OutDir, filepath.Join(fDir...), fmt.Sprintf("%s.%s", baseNoExt(fileName), ext)) fName = filepath.Join(outDir, fmt.Sprintf("%s.%s", baseNoExt(fileName), ext))
} else { } else {
fName = filepath.Join(c.Opts.OutDir, fmt.Sprintf("%s.%s", baseNoExt(fileName), ext)) fName = filepath.Join(c.Opts.OutDir, fmt.Sprintf("%s.%s", baseNoExt(fileName), ext))
} }
@@ -310,8 +386,10 @@ func (c *Converter) Cover(fileName string, fileInfo os.FileInfo) error {
} }
// Thumbnail extracts thumbnail. // Thumbnail extracts thumbnail.
func (c *Converter) Thumbnail(fileName string, fileInfo os.FileInfo) error { func (c *Converter) Thumbnail(file File) error {
c.CurrFile++ c.CurrFile++
c.root = file.Root
fileName, fileInfo := file.Path, file.Stat
cover, err := c.coverImage(fileName, fileInfo) cover, err := c.coverImage(fileName, fileInfo)
if err != nil { if err != nil {
@@ -352,13 +430,12 @@ func (c *Converter) Thumbnail(fileName string, fileInfo os.FileInfo) error {
fURI = "file://" + fileName fURI = "file://" + fileName
if c.Opts.Recursive { if c.Opts.Recursive {
fDir := strings.Split(filepath.Dir(fileName), string(os.PathSeparator))[1:] outDir := c.recursiveDir(fileName)
err := os.MkdirAll(filepath.Join(c.Opts.OutDir, filepath.Join(fDir...)), 0755) if err := os.MkdirAll(outDir, 0755); err != nil {
if err != nil {
return fmt.Errorf("%s: %w", fileName, err) return fmt.Errorf("%s: %w", fileName, err)
} }
fName = filepath.Join(c.Opts.OutDir, filepath.Join(fDir...), fmt.Sprintf("%x.png", md5.Sum([]byte(fURI)))) fName = filepath.Join(outDir, fmt.Sprintf("%x.png", md5.Sum([]byte(fURI))))
} else { } else {
fName = filepath.Join(c.Opts.OutDir, fmt.Sprintf("%x.png", md5.Sum([]byte(fURI)))) fName = filepath.Join(c.Opts.OutDir, fmt.Sprintf("%x.png", md5.Sum([]byte(fURI))))
} }
@@ -496,8 +573,10 @@ func (c *Converter) Preview(fileName string, fileInfo os.FileInfo, width, height
} }
// Convert converts a comic book. // Convert converts a comic book.
func (c *Converter) Convert(fileName string, fileInfo os.FileInfo) error { func (c *Converter) Convert(file File) error {
c.CurrFile++ c.CurrFile++
c.root = file.Root
fileName, fileInfo := file.Path, file.Stat
ctx, cancel := context.WithCancel(context.Background()) ctx, cancel := context.WithCancel(context.Background())
defer cancel() defer cancel()
@@ -541,6 +620,8 @@ func (c *Converter) Combine(files []File) error {
return nil return nil
} }
c.root = ""
ctx, cancel := context.WithCancel(context.Background()) ctx, cancel := context.WithCancel(context.Background())
defer cancel() defer cancel()
+17 -8
View File
@@ -3,6 +3,7 @@ package cbconvert
import ( import (
"archive/tar" "archive/tar"
"archive/zip" "archive/zip"
"compress/flate"
"context" "context"
"fmt" "fmt"
"io" "io"
@@ -33,13 +34,12 @@ func (c *Converter) archiveSaveZip(fileName string) error {
var zipName string var zipName string
if c.Opts.Recursive { if c.Opts.Recursive {
fDir := strings.Split(filepath.Dir(fileName), string(os.PathSeparator))[1:] outDir := c.recursiveDir(fileName)
err := os.MkdirAll(filepath.Join(c.Opts.OutDir, filepath.Join(fDir...)), 0755) if err := os.MkdirAll(outDir, 0755); err != nil {
if err != nil {
return fmt.Errorf("archiveSaveZip: %w", err) return fmt.Errorf("archiveSaveZip: %w", err)
} }
zipName = filepath.Join(c.Opts.OutDir, filepath.Join(fDir...), fmt.Sprintf("%s%s.cbz", baseNoExt(fileName), c.Opts.Suffix)) zipName = filepath.Join(outDir, fmt.Sprintf("%s%s.cbz", baseNoExt(fileName), c.Opts.Suffix))
} else { } else {
zipName = filepath.Join(c.Opts.OutDir, fmt.Sprintf("%s%s.cbz", baseNoExt(fileName), c.Opts.Suffix)) zipName = filepath.Join(c.Opts.OutDir, fmt.Sprintf("%s%s.cbz", baseNoExt(fileName), c.Opts.Suffix))
} }
@@ -51,6 +51,13 @@ func (c *Converter) archiveSaveZip(fileName string) error {
z := zip.NewWriter(zipFile) z := zip.NewWriter(zipFile)
if c.Opts.ZipLevel >= 1 {
level := c.Opts.ZipLevel
z.RegisterCompressor(zip.Deflate, func(out io.Writer) (io.WriteCloser, error) {
return flate.NewWriter(out, level)
})
}
files, err := os.ReadDir(c.Workdir) files, err := os.ReadDir(c.Workdir)
if err != nil { if err != nil {
return fmt.Errorf("archiveSaveZip: %w", err) return fmt.Errorf("archiveSaveZip: %w", err)
@@ -73,6 +80,9 @@ func (c *Converter) archiveSaveZip(fileName string) error {
} }
zipInfo.Method = zip.Deflate zipInfo.Method = zip.Deflate
if c.Opts.ZipLevel == 0 {
zipInfo.Method = zip.Store
}
w, err := z.CreateHeader(zipInfo) w, err := z.CreateHeader(zipInfo)
if err != nil { if err != nil {
return fmt.Errorf("archiveSaveZip: %w", err) return fmt.Errorf("archiveSaveZip: %w", err)
@@ -108,13 +118,12 @@ func (c *Converter) archiveSaveTar(fileName string) error {
var tarName string var tarName string
if c.Opts.Recursive { if c.Opts.Recursive {
fDir := strings.Split(filepath.Dir(fileName), string(os.PathSeparator))[1:] outDir := c.recursiveDir(fileName)
err := os.MkdirAll(filepath.Join(c.Opts.OutDir, filepath.Join(fDir...)), 0755) if err := os.MkdirAll(outDir, 0755); err != nil {
if err != nil {
return fmt.Errorf("archiveSaveTar: %w", err) return fmt.Errorf("archiveSaveTar: %w", err)
} }
tarName = filepath.Join(c.Opts.OutDir, filepath.Join(fDir...), fmt.Sprintf("%s%s.cbt", baseNoExt(fileName), c.Opts.Suffix)) tarName = filepath.Join(outDir, fmt.Sprintf("%s%s.cbt", baseNoExt(fileName), c.Opts.Suffix))
} else { } else {
tarName = filepath.Join(c.Opts.OutDir, fmt.Sprintf("%s%s.cbt", baseNoExt(fileName), c.Opts.Suffix)) tarName = filepath.Join(c.Opts.OutDir, fmt.Sprintf("%s%s.cbt", baseNoExt(fileName), c.Opts.Suffix))
} }
+595 -3
View File
@@ -1,9 +1,12 @@
package cbconvert package cbconvert
import ( import (
"archive/zip"
"fmt" "fmt"
"image"
"os" "os"
"path/filepath" "path/filepath"
"strings"
"testing" "testing"
) )
@@ -29,7 +32,7 @@ func TestConvert(t *testing.T) {
for _, file := range files { for _, file := range files {
conv.Opts.Suffix = fmt.Sprintf("_%s%s", format, filepath.Ext(file.Path)) conv.Opts.Suffix = fmt.Sprintf("_%s%s", format, filepath.Ext(file.Path))
err = conv.Convert(file.Path, file.Stat) err = conv.Convert(file)
if err != nil { if err != nil {
t.Errorf("format %s: file %s: %v", format, file.Name, err) t.Errorf("format %s: file %s: %v", format, file.Name, err)
} }
@@ -59,7 +62,7 @@ func TestCover(t *testing.T) {
} }
for _, file := range files { for _, file := range files {
err = conv.Cover(file.Path, file.Stat) err = conv.Cover(file)
if err != nil { if err != nil {
t.Error(err) t.Error(err)
} }
@@ -88,7 +91,7 @@ func TestThumbnail(t *testing.T) {
} }
for _, file := range files { for _, file := range files {
err = conv.Thumbnail(file.Path, file.Stat) err = conv.Thumbnail(file)
if err != nil { if err != nil {
t.Error(err) t.Error(err)
} }
@@ -99,3 +102,592 @@ func TestThumbnail(t *testing.T) {
t.Error(err) t.Error(err)
} }
} }
func TestArgs(t *testing.T) {
opts := NewOptions()
if got := opts.Args(); len(got) != 0 {
t.Errorf("defaults should emit no flags, got %v", got)
}
opts.Format = "webp"
opts.Quality = 90
opts.Effort = 4
opts.Lossless = true
opts.Width = 1200
opts.Grayscale = true
opts.OutDir = "/out"
got := strings.Join(opts.Args(), " ")
want := "--width 1200 --format webp --quality 90 --effort 4 --lossless --grayscale --outdir /out"
if got != want {
t.Errorf("Args() = %q, want %q", got, want)
}
}
func TestConvertResize(t *testing.T) {
tmpDir, err := os.MkdirTemp(os.TempDir(), "cbc")
if err != nil {
t.Fatal(err)
}
defer os.RemoveAll(tmpDir)
opts := NewOptions()
opts.OutDir = tmpDir
opts.Width = 100
conv := New(opts)
files, err := conv.Files([]string{"testdata/test.cbz"})
if err != nil {
t.Fatal(err)
}
for _, file := range files {
if err := conv.Convert(file); err != nil {
t.Fatal(err)
}
}
img := firstPage(t, conv, filepath.Join(tmpDir, "test.cbz"))
if got := img.Bounds().Dx(); got != 100 {
t.Errorf("resized width: got %d, want 100", got)
}
}
func TestConvertFit(t *testing.T) {
tmpDir, err := os.MkdirTemp(os.TempDir(), "cbc")
if err != nil {
t.Fatal(err)
}
defer os.RemoveAll(tmpDir)
opts := NewOptions()
opts.OutDir = tmpDir
opts.Width = 120
opts.Height = 120
opts.Fit = true
conv := New(opts)
files, err := conv.Files([]string{"testdata/test.cbz"})
if err != nil {
t.Fatal(err)
}
for _, file := range files {
if err := conv.Convert(file); err != nil {
t.Fatal(err)
}
}
img := firstPage(t, conv, filepath.Join(tmpDir, "test.cbz"))
w, h := img.Bounds().Dx(), img.Bounds().Dy()
if w > 120 || h > 120 {
t.Errorf("fit exceeded bounds: got %dx%d, want <= 120x120", w, h)
}
if w != 120 && h != 120 {
t.Errorf("fit did not touch a bound: got %dx%d, want one side == 120", w, h)
}
}
func TestConvertTar(t *testing.T) {
tmpDir, err := os.MkdirTemp(os.TempDir(), "cbc")
if err != nil {
t.Fatal(err)
}
defer os.RemoveAll(tmpDir)
opts := NewOptions()
opts.OutDir = tmpDir
opts.Archive = "tar"
conv := New(opts)
files, err := conv.Files([]string{"testdata/test.cbz"})
if err != nil {
t.Fatal(err)
}
for _, file := range files {
if err := conv.Convert(file); err != nil {
t.Fatal(err)
}
}
out := filepath.Join(tmpDir, "test.cbt")
list, err := conv.archiveList(out)
if err != nil {
t.Fatalf("read tar output: %v", err)
}
if len(list) != 2 {
t.Errorf("expected 2 pages in tar output, got %d: %v", len(list), list)
}
}
func TestZipLevel(t *testing.T) {
convertWith := func(level int) *zip.ReadCloser {
tmpDir, err := os.MkdirTemp(os.TempDir(), "cbc")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { os.RemoveAll(tmpDir) })
opts := NewOptions()
opts.OutDir = tmpDir
opts.ZipLevel = level
opts.NoConvert = true
conv := New(opts)
files, err := conv.Files([]string{"testdata/test.cbz"})
if err != nil {
t.Fatal(err)
}
for _, file := range files {
if err := conv.Convert(file); err != nil {
t.Fatal(err)
}
}
zr, err := zip.OpenReader(filepath.Join(tmpDir, "test.cbz"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { zr.Close() })
return zr
}
store := convertWith(0)
for _, f := range store.File {
if f.Method != zip.Store {
t.Errorf("level 0: %s stored with method %d, want Store", f.Name, f.Method)
}
if f.CompressedSize64 != f.UncompressedSize64 {
t.Errorf("level 0: %s is compressed (%d < %d)", f.Name, f.CompressedSize64, f.UncompressedSize64)
}
}
deflate := convertWith(9)
for _, f := range deflate.File {
if f.Method != zip.Deflate {
t.Errorf("level 9: %s method %d, want Deflate", f.Name, f.Method)
}
}
}
func TestImageTransforms(t *testing.T) {
conv := New(NewOptions())
f, err := os.Open("testdata/test/00.jpg")
if err != nil {
t.Fatal(err)
}
defer f.Close()
src, err := conv.imageDecode(f)
if err != nil {
t.Fatal(err)
}
srcW, srcH := src.Bounds().Dx(), src.Bounds().Dy()
conv.Opts.Rotate = 90
rotated := conv.imageTransform(src)
if rotated.Bounds().Dx() != srcH || rotated.Bounds().Dy() != srcW {
t.Errorf("rotate 90: got %dx%d, want %dx%d", rotated.Bounds().Dx(), rotated.Bounds().Dy(), srcH, srcW)
}
conv.Opts = NewOptions()
conv.Opts.Grayscale = true
gray := conv.imageTransform(src)
if !isGrayScale(gray) {
t.Errorf("grayscale: result is not grayscale")
}
conv.Opts = NewOptions()
conv.Opts.Brightness = 20
conv.Opts.Contrast = 20
adjusted := conv.imageTransform(src)
if adjusted.Bounds().Dx() != srcW || adjusted.Bounds().Dy() != srcH {
t.Errorf("brightness/contrast changed dimensions: got %dx%d, want %dx%d",
adjusted.Bounds().Dx(), adjusted.Bounds().Dy(), srcW, srcH)
}
}
func TestCoverName(t *testing.T) {
conv := New(NewOptions())
tests := []struct {
name string
images []string
want string
}{
{"empty", nil, ""},
{"natural sort", []string{"10.jpg", "2.jpg", "1.jpg"}, "1.jpg"},
{"cover prefix wins", []string{"01.jpg", "cover.jpg", "02.jpg"}, "cover.jpg"},
{"front prefix wins", []string{"01.jpg", "front.png", "00.jpg"}, "front.png"},
{"cover suffix wins", []string{"01.jpg", "page_cover.jpg"}, "page_cover.jpg"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := conv.coverName(tt.images); got != tt.want {
t.Errorf("coverName(%v) = %q, want %q", tt.images, got, tt.want)
}
})
}
}
func TestCoverDirectory(t *testing.T) {
tmpDir, err := os.MkdirTemp(os.TempDir(), "cbc")
if err != nil {
t.Fatal(err)
}
defer os.RemoveAll(tmpDir)
opts := NewOptions()
opts.OutDir = tmpDir
conv := New(opts)
files, err := conv.Files([]string{"testdata/test"})
if err != nil {
t.Fatal(err)
}
if len(files) != 1 {
t.Fatalf("expected 1 directory file, got %d", len(files))
}
for _, file := range files {
if err := conv.Cover(file); err != nil {
t.Fatal(err)
}
}
if _, err := os.Stat(filepath.Join(tmpDir, "test.jpg")); err != nil {
t.Errorf("directory cover not written: %v", err)
}
}
func TestFileType(t *testing.T) {
tests := []struct {
path string
want string
}{
{"testdata/test.cbz", "ZIP"},
{"testdata/test.cbr", "RAR"},
{"testdata/test.cb7", "7Z"},
{"testdata/test.cbt", "TAR"},
{"testdata/test.pdf", "PDF"},
{"testdata/test", "DIR"},
}
for _, tt := range tests {
t.Run(tt.path, func(t *testing.T) {
if got := FileType(tt.path); got != tt.want {
t.Errorf("FileType(%q) = %q, want %q", tt.path, got, tt.want)
}
})
}
}
func TestCombine(t *testing.T) {
tmpDir, err := os.MkdirTemp(os.TempDir(), "cbc")
if err != nil {
t.Fatal(err)
}
defer os.RemoveAll(tmpDir)
opts := NewOptions()
opts.OutDir = tmpDir
opts.OutFile = "merged"
conv := New(opts)
files, err := conv.Files([]string{"testdata/test.cbz", "testdata/test.cbt"})
if err != nil {
t.Fatal(err)
}
if len(files) != 2 {
t.Fatalf("expected 2 input files, got %d", len(files))
}
if err := conv.Combine(files); err != nil {
t.Fatal(err)
}
zr, err := zip.OpenReader(filepath.Join(tmpDir, "merged.cbz"))
if err != nil {
t.Fatalf("open combined archive: %v", err)
}
defer zr.Close()
var names []string
for _, f := range zr.File {
names = append(names, f.Name)
}
if len(names) != 4 {
t.Fatalf("expected 4 pages in combined archive, got %d: %v", len(names), names)
}
// each input is prefixed so identically named pages do not collide
var first, second int
for _, n := range names {
switch {
case strings.HasPrefix(n, "0001_"):
first++
case strings.HasPrefix(n, "0002_"):
second++
}
}
if first != 2 || second != 2 {
t.Errorf("expected 2 pages from each input, got 0001_=%d 0002_=%d: %v", first, second, names)
}
}
func TestSubfolders(t *testing.T) {
page0, err := os.ReadFile("testdata/test/00.jpg")
if err != nil {
t.Fatal(err)
}
page1, err := os.ReadFile("testdata/test/01.jpg")
if err != nil {
t.Fatal(err)
}
inDir, err := os.MkdirTemp(os.TempDir(), "cbc-in")
if err != nil {
t.Fatal(err)
}
defer os.RemoveAll(inDir)
src := filepath.Join(inDir, "chapters.cbz")
buildZip(t, src, []zipEntry{
{"chapter1/00.jpg", page0},
{"chapter1/01.jpg", page1},
{"chapter2/00.jpg", page0},
{"chapter2/01.jpg", page1},
})
tmpDir, err := os.MkdirTemp(os.TempDir(), "cbc-out")
if err != nil {
t.Fatal(err)
}
defer os.RemoveAll(tmpDir)
opts := NewOptions()
opts.OutDir = tmpDir
conv := New(opts)
files, err := conv.Files([]string{src})
if err != nil {
t.Fatal(err)
}
for _, file := range files {
if err := conv.Convert(file); err != nil {
t.Fatal(err)
}
}
zr, err := zip.OpenReader(filepath.Join(tmpDir, "chapters.cbz"))
if err != nil {
t.Fatalf("open output archive: %v", err)
}
defer zr.Close()
// without subfolder preservation chapter2/00 overwrites chapter1/00 and only 2 pages survive
if len(zr.File) != 4 {
var names []string
for _, f := range zr.File {
names = append(names, f.Name)
}
t.Fatalf("expected 4 pages from numbered subfolders, got %d: %v", len(zr.File), names)
}
}
func TestMeta(t *testing.T) {
tmpDir, err := os.MkdirTemp(os.TempDir(), "cbc")
if err != nil {
t.Fatal(err)
}
defer os.RemoveAll(tmpDir)
// operate on a copy so the fixture stays intact
data, err := os.ReadFile("testdata/test.cbz")
if err != nil {
t.Fatal(err)
}
archive := filepath.Join(tmpDir, "meta.cbz")
if err := os.WriteFile(archive, data, 0644); err != nil {
t.Fatal(err)
}
conv := New(NewOptions())
conv.Opts = NewOptions()
conv.Opts.CommentBody = "hello world"
if _, err := conv.Meta(archive); err != nil {
t.Fatalf("set comment: %v", err)
}
conv.Opts = NewOptions()
conv.Opts.Comment = true
got, err := conv.Meta(archive)
if err != nil {
t.Fatalf("get comment: %v", err)
}
if got != "hello world" {
t.Errorf("comment roundtrip: got %q, want %q", got, "hello world")
}
extra := filepath.Join(tmpDir, "ComicInfo.xml")
if err := os.WriteFile(extra, []byte("<ComicInfo/>"), 0644); err != nil {
t.Fatal(err)
}
conv.Opts = NewOptions()
conv.Opts.FileAdd = extra
if _, err := conv.Meta(archive); err != nil {
t.Fatalf("add file: %v", err)
}
if !archiveHas(t, conv, archive, "ComicInfo.xml") {
t.Errorf("added file not found in archive")
}
conv.Opts = NewOptions()
conv.Opts.FileRemove = "ComicInfo.xml"
if _, err := conv.Meta(archive); err != nil {
t.Fatalf("remove file: %v", err)
}
if archiveHas(t, conv, archive, "ComicInfo.xml") {
t.Errorf("removed file still present in archive")
}
}
func TestRecursive(t *testing.T) {
inDir, err := os.MkdirTemp(os.TempDir(), "cbc-in")
if err != nil {
t.Fatal(err)
}
defer os.RemoveAll(inDir)
sub := filepath.Join(inDir, "chapter1")
if err := os.MkdirAll(sub, 0755); err != nil {
t.Fatal(err)
}
src, err := os.ReadFile("testdata/test.cbz")
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(sub, "test.cbz"), src, 0644); err != nil {
t.Fatal(err)
}
outDir, err := os.MkdirTemp(os.TempDir(), "cbc-out")
if err != nil {
t.Fatal(err)
}
defer os.RemoveAll(outDir)
opts := NewOptions()
opts.OutDir = outDir
opts.Recursive = true
conv := New(opts)
files, err := conv.Files([]string{inDir})
if err != nil {
t.Fatal(err)
}
if len(files) != 1 {
t.Fatalf("expected 1 file, got %d", len(files))
}
for _, file := range files {
if err := conv.Convert(file); err != nil {
t.Error(err)
}
}
// output must mirror the input subtree relative to the input root, not the absolute path
want := filepath.Join(outDir, "chapter1", "test.cbz")
if _, err := os.Stat(want); err != nil {
t.Errorf("expected output relative to input root at %s: %v", want, err)
}
}
type zipEntry struct {
name string
data []byte
}
func buildZip(t *testing.T, path string, entries []zipEntry) {
t.Helper()
f, err := os.Create(path)
if err != nil {
t.Fatal(err)
}
defer f.Close()
zw := zip.NewWriter(f)
for _, e := range entries {
w, err := zw.Create(e.name)
if err != nil {
t.Fatal(err)
}
if _, err := w.Write(e.data); err != nil {
t.Fatal(err)
}
}
if err := zw.Close(); err != nil {
t.Fatal(err)
}
}
func firstPage(t *testing.T, conv *Converter, archive string) image.Image {
t.Helper()
zr, err := zip.OpenReader(archive)
if err != nil {
t.Fatal(err)
}
defer zr.Close()
if len(zr.File) == 0 {
t.Fatalf("archive %s has no entries", archive)
}
rc, err := zr.File[0].Open()
if err != nil {
t.Fatal(err)
}
defer rc.Close()
img, err := conv.imageDecode(rc)
if err != nil {
t.Fatal(err)
}
return img
}
func archiveHas(t *testing.T, conv *Converter, archive, name string) bool {
t.Helper()
list, err := conv.archiveList(archive)
if err != nil {
t.Fatal(err)
}
for _, n := range list {
if n == name {
return true
}
}
return false
}
+381 -30
View File
@@ -34,8 +34,58 @@ var appVersion string
var ( var (
index = -1 index = -1
files []cbconvert.File files []cbconvert.File
config iup.Ihandle
) )
const (
pathsGroup = "Paths"
profilesGroup = "Profiles"
inputDirKey = "InputDir"
outputDirKey = "OutputDir"
)
type settingKind int
const (
kindBool settingKind = iota
kindInt
kindStr
)
type setting struct {
handle string
kind settingKind
def string
}
var settings = []setting{
{"Recursive", kindBool, "OFF"},
{"NoRGB", kindBool, "OFF"},
{"NoCover", kindBool, "OFF"},
{"NoConvert", kindBool, "OFF"},
{"NoNonImage", kindBool, "OFF"},
{"Combine", kindBool, "OFF"},
{"Fit", kindBool, "OFF"},
{"Lossless", kindBool, "OFF"},
{"Grayscale", kindBool, "OFF"},
{"OutDir", kindStr, ""},
{"Suffix", kindStr, ""},
{"Width", kindStr, ""},
{"Height", kindStr, ""},
{"Size", kindInt, "0"},
{"Quality", kindInt, "75"},
{"Effort", kindInt, "0"},
{"Brightness", kindInt, "0"},
{"Contrast", kindInt, "0"},
{"Format", kindInt, "1"},
{"Archive", kindInt, "1"},
{"ZipLevel", kindInt, "1"},
{"Filter", kindInt, "3"},
{"Rotate", kindInt, "1"},
}
func init() { func init() {
if appVersion != "" { if appVersion != "" {
return return
@@ -69,8 +119,13 @@ func main() {
iup.Open() iup.Open()
defer iup.Close() defer iup.Close()
iup.SetGlobal("APPNAME", "cbconvert")
iup.SetGlobal("APPID", "io.github.gen2brain.cbconvert")
iup.SetGlobal("AUTODARKMODE", "YES") iup.SetGlobal("AUTODARKMODE", "YES")
config = iup.Config()
iup.ConfigLoad(config)
img, _ := png.Decode(bytes.NewReader(appLogo)) img, _ := png.Decode(bytes.NewReader(appLogo))
iup.ImageFromImage(img).SetHandle("logo") iup.ImageFromImage(img).SetHandle("logo")
@@ -103,7 +158,7 @@ func main() {
})) }))
iup.Map(dlg) iup.Map(dlg)
setActive() profilesInit()
iup.ShowXY(dlg, iup.CENTER, iup.CENTER) iup.ShowXY(dlg, iup.CENTER, iup.CENTER)
iup.MainLoop() iup.MainLoop()
@@ -141,6 +196,7 @@ func options() cbconvert.Options {
opts.NoConvert = iup.GetHandle("NoConvert").GetAttribute("VALUE") == "ON" opts.NoConvert = iup.GetHandle("NoConvert").GetAttribute("VALUE") == "ON"
opts.NoNonImage = iup.GetHandle("NoNonImage").GetAttribute("VALUE") == "ON" opts.NoNonImage = iup.GetHandle("NoNonImage").GetAttribute("VALUE") == "ON"
opts.Archive = strings.ToLower(iup.GetHandle("Archive").GetAttribute("VALUESTRING")) opts.Archive = strings.ToLower(iup.GetHandle("Archive").GetAttribute("VALUESTRING"))
opts.ZipLevel = zipLevel(iup.GetHandle("ZipLevel").GetAttribute("VALUESTRING"))
opts.Format = strings.ToLower(iup.GetHandle("Format").GetAttribute("VALUESTRING")) opts.Format = strings.ToLower(iup.GetHandle("Format").GetAttribute("VALUESTRING"))
opts.Width = iup.GetHandle("Width").GetInt("VALUE") opts.Width = iup.GetHandle("Width").GetInt("VALUE")
opts.Height = iup.GetHandle("Height").GetInt("VALUE") opts.Height = iup.GetHandle("Height").GetInt("VALUE")
@@ -246,6 +302,246 @@ func setActive() {
} else { } else {
iup.GetHandle("VboxOutFile").SetAttribute("ACTIVE", "NO") iup.GetHandle("VboxOutFile").SetAttribute("ACTIVE", "NO")
} }
if opts.Archive == "zip" {
iup.GetHandle("VboxZipLevel").SetAttribute("ACTIVE", "YES")
} else {
iup.GetHandle("VboxZipLevel").SetAttribute("ACTIVE", "NO")
}
}
// shellArg quotes a command-line argument that contains whitespace.
func shellArg(s string) string {
if strings.ContainsAny(s, " \t") {
return `"` + strings.ReplaceAll(s, `"`, `\"`) + `"`
}
return s
}
func commandLine() string {
parts := append([]string{"cbconvert", "convert"}, options().Args()...)
for _, file := range files {
parts = append(parts, file.Path)
}
for i, p := range parts {
parts[i] = shellArg(p)
}
return strings.Join(parts, " ")
}
func onCommand(iup.Ihandle) int {
iup.GetText("Command Line", commandLine(), -1)
return iup.DEFAULT
}
// zipLevel maps the compression dropdown selection to Options.ZipLevel.
func zipLevel(value string) int {
switch value {
case "Default":
return -1
case "Store (none)":
return 0
default:
level, err := strconv.Atoi(value)
if err != nil {
return -1
}
return level
}
}
func profileGroup(name string) string {
return "Profile:" + name
}
func profileNames() []string {
s := iup.ConfigGetVariableStr(config, profilesGroup, "Names")
if s == "" {
return nil
}
return strings.Split(s, ";")
}
func currentProfile() string {
return iup.ConfigGetVariableStrDef(config, profilesGroup, "Current", "Default")
}
func setStartDir(dlg iup.Ihandle, key string) {
if dir := iup.ConfigGetVariableStr(config, pathsGroup, key); dir != "" {
dlg.SetAttribute("DIRECTORY", dir)
}
}
func rememberDir(dlg iup.Ihandle, key string) {
dir := dlg.GetAttribute("DIRECTORY")
if dir == "" {
return
}
iup.ConfigSetVariableStr(config, pathsGroup, key, dir)
iup.ConfigSave(config)
}
func settingsSave(group string) {
for _, s := range settings {
h := iup.GetHandle(s.handle)
switch s.kind {
case kindBool:
v := 0
if h.GetAttribute("VALUE") == "ON" {
v = 1
}
iup.ConfigSetVariableInt(config, group, s.handle, v)
case kindInt:
iup.ConfigSetVariableInt(config, group, s.handle, h.GetInt("VALUE"))
case kindStr:
iup.ConfigSetVariableStr(config, group, s.handle, h.GetAttribute("VALUE"))
}
}
iup.ConfigSave(config)
}
// settingsApply sets every control from the given profile group, or from defaults when group is empty.
func settingsApply(group string) {
for _, s := range settings {
h := iup.GetHandle(s.handle)
switch s.kind {
case kindBool:
def := 0
if s.def == "ON" {
def = 1
}
v := def
if group != "" {
v = iup.ConfigGetVariableIntDef(config, group, s.handle, def)
}
if v != 0 {
h.SetAttribute("VALUE", "ON")
} else {
h.SetAttribute("VALUE", "OFF")
}
case kindInt:
def, _ := strconv.Atoi(s.def)
v := def
if group != "" {
v = iup.ConfigGetVariableIntDef(config, group, s.handle, def)
}
h.SetAttribute("VALUE", strconv.Itoa(v))
case kindStr:
v := s.def
if group != "" {
v = iup.ConfigGetVariableStrDef(config, group, s.handle, s.def)
}
h.SetAttribute("VALUE", v)
}
}
syncLabels()
setActive()
previewPost()
}
// syncLabels mirrors slider values into their value labels and retunes the effort slider for the current format.
func syncLabels() {
iup.GetHandle("LabelQuality").SetAttribute("TITLE", iup.GetHandle("Quality").GetInt("VALUE"))
iup.GetHandle("LabelBrightness").SetAttribute("TITLE", iup.GetHandle("Brightness").GetInt("VALUE"))
iup.GetHandle("LabelContrast").SetAttribute("TITLE", iup.GetHandle("Contrast").GetInt("VALUE"))
format := strings.ToLower(iup.GetHandle("Format").GetAttribute("VALUESTRING"))
eff := iup.GetHandle("Effort").GetInt("VALUE")
setEffort(format)
switch format {
case "webp", "avif", "jxl":
val := iup.GetHandle("Effort")
val.SetAttribute("VALUE", strconv.Itoa(eff))
iup.GetHandle("LabelEffort").SetAttribute("TITLE", fmt.Sprintf("%s: %d", val.GetAttribute("EFFORTNAME"), eff))
}
iup.Refresh(iup.GetHandle("Tabs"))
}
func fillProfileList() {
list := iup.GetHandle("Profile")
list.SetAttribute("REMOVEITEM", "ALL")
cur := currentProfile()
sel := 1
for i, n := range profileNames() {
list.SetAttribute(strconv.Itoa(i+1), n)
if n == cur {
sel = i + 1
}
}
list.SetAttribute("VALUE", strconv.Itoa(sel))
}
// profilesInit loads the current profile on startup, creating a default one on first run.
func profilesInit() {
if len(profileNames()) == 0 {
iup.ConfigSetVariableStr(config, profilesGroup, "Names", "Default")
iup.ConfigSetVariableStr(config, profilesGroup, "Current", "Default")
settingsSave(profileGroup("Default"))
}
fillProfileList()
settingsApply(profileGroup(currentProfile()))
}
func onProfileSelect(ih iup.Ihandle) int {
name := ih.GetAttribute("VALUESTRING")
if name == "" {
return iup.DEFAULT
}
iup.ConfigSetVariableStr(config, profilesGroup, "Current", name)
iup.ConfigSave(config)
settingsApply(profileGroup(name))
return iup.DEFAULT
}
func onSave(iup.Ihandle) int {
name := currentProfile()
if iup.GetParam("Save Profile", nil, "Name: %s\n", &name) != 1 {
return iup.DEFAULT
}
name = strings.TrimSpace(name)
if name == "" || strings.ContainsAny(name, ".;") {
iup.Message("Invalid Name", "Profile name must not be empty or contain '.' or ';'.")
return iup.DEFAULT
}
settingsSave(profileGroup(name))
names := profileNames()
if !slices.Contains(names, name) {
names = append(names, name)
iup.ConfigSetVariableStr(config, profilesGroup, "Names", strings.Join(names, ";"))
}
iup.ConfigSetVariableStr(config, profilesGroup, "Current", name)
iup.ConfigSave(config)
fillProfileList()
return iup.DEFAULT
}
func onReset(iup.Ihandle) int {
settingsApply("")
return iup.DEFAULT
} }
func setEffort(format string) { func setEffort(format string) {
@@ -521,8 +817,32 @@ func tabs() iup.Ihandle {
"VALUE": "1", "VALUE": "1",
"1": "ZIP", "1": "ZIP",
"2": "TAR", "2": "TAR",
}).SetHandle("Archive"), }).SetHandle("Archive").
SetCallback("VALUECHANGED_CB", iup.ValueChangedFunc(func(ih iup.Ihandle) int {
setActive()
return iup.DEFAULT
})),
), ),
iup.Vbox(
iup.Label("Compression:"),
iup.List().SetAttributes(map[string]string{
"DROPDOWN": "YES",
"VALUE": "1",
"1": "Default",
"2": "Store (none)",
"3": "1",
"4": "2",
"5": "3",
"6": "4",
"7": "5",
"8": "6",
"9": "7",
"10": "8",
"11": "9",
}).SetHandle("ZipLevel").
SetAttribute("TIP", "ZIP compression: Store disables it, 1 is fastest, 9 is smallest"),
).SetHandle("VboxZipLevel"),
).SetAttributes("NGAP=10"), ).SetAttributes("NGAP=10"),
iup.Space().SetAttribute("SIZE", "15"), iup.Space().SetAttribute("SIZE", "15"),
iup.Vbox( iup.Vbox(
@@ -784,8 +1104,23 @@ func buttons() iup.Ihandle {
SetCallback("ACTION", iup.ActionFunc(onCover)) SetCallback("ACTION", iup.ActionFunc(onCover))
convert := iup.Button("&Convert").SetHandle("Convert").SetAttributes("PADDING=DEFAULTBUTTONPADDING"). convert := iup.Button("&Convert").SetHandle("Convert").SetAttributes("PADDING=DEFAULTBUTTONPADDING").
SetCallback("ACTION", iup.ActionFunc(onConvert)) SetCallback("ACTION", iup.ActionFunc(onConvert))
reset := iup.Button("Reset").SetHandle("Reset").SetAttributes("PADDING=DEFAULTBUTTONPADDING").
SetAttribute("TIP", "Restore all settings to their defaults").
SetCallback("ACTION", iup.ActionFunc(onReset))
save := iup.Button("Save").SetHandle("Save").SetAttributes("PADDING=DEFAULTBUTTONPADDING").
SetAttribute("TIP", "Save current settings to a profile").
SetCallback("ACTION", iup.ActionFunc(onSave))
iup.Normalizer(addFiles, addDir, remove, removeAll, thumbnail, cover, convert).SetAttribute("NORMALIZE", "BOTH") command := iup.Button("Command").SetAttributes("PADDING=DEFAULTBUTTONPADDING").
SetAttribute("TIP", "Show the equivalent command line").
SetCallback("ACTION", iup.ActionFunc(onCommand))
profile := iup.List().SetAttributes("DROPDOWN=YES").SetHandle("Profile").
SetAttribute("TIP", "Select a settings profile").
SetCallback("VALUECHANGED_CB", iup.ValueChangedFunc(onProfileSelect))
iup.Normalizer(addFiles, addDir, remove, removeAll, thumbnail, cover, convert, reset, save, command).SetAttribute("NORMALIZE", "BOTH")
iup.Normalizer(addFiles, addDir, remove, removeAll, thumbnail, cover, convert, reset, save, command, profile).SetAttribute("NORMALIZE", "HORIZONTAL")
return iup.Vbox( return iup.Vbox(
iup.Vbox( iup.Vbox(
@@ -794,15 +1129,23 @@ func buttons() iup.Ihandle {
remove, remove,
removeAll, removeAll,
).SetAttribute("NGAP", "2"), ).SetAttribute("NGAP", "2"),
iup.Space().SetAttribute("SIZE", "x5"), iup.Space().SetAttribute("SIZE", "x8"),
iup.Vbox( iup.Vbox(
thumbnail, thumbnail,
cover, cover,
).SetAttribute("NGAP", "2"), ).SetAttribute("NGAP", "2"),
iup.Space().SetAttribute("SIZE", "x5"), iup.Space().SetAttribute("SIZE", "x8"),
iup.Vbox( iup.Vbox(
convert, convert,
), ),
iup.Fill(),
iup.Vbox(
iup.Label("Profile:"),
profile,
reset,
save,
command,
).SetAttribute("NGAP", "2"),
).SetHandle("Buttons").SetAttributes("ALIGNMENT=ACENTER") ).SetHandle("Buttons").SetAttributes("ALIGNMENT=ACENTER")
} }
@@ -888,7 +1231,7 @@ func loading() iup.Ihandle {
} }
func onAddFiles(ih iup.Ihandle) int { func onAddFiles(ih iup.Ihandle) int {
args, err := fileDlg("Add Files", true, false) args, err := fileDlg("Add Files", true, false, inputDirKey)
if err != nil { if err != nil {
iup.PostMessage(iup.GetHandle("dlg"), err.Error(), 0, 0) iup.PostMessage(iup.GetHandle("dlg"), err.Error(), 0, 0)
fmt.Println(err) fmt.Println(err)
@@ -928,7 +1271,7 @@ func onAddFiles(ih iup.Ihandle) int {
} }
func onAddDir(ih iup.Ihandle) int { func onAddDir(ih iup.Ihandle) int {
args, err := fileDlg("Add Directory", false, true) args, err := fileDlg("Add Directory", false, true, inputDirKey)
if err != nil { if err != nil {
iup.PostMessage(iup.GetHandle("dlg"), err.Error(), 0, 0) iup.PostMessage(iup.GetHandle("dlg"), err.Error(), 0, 0)
fmt.Println(err) fmt.Println(err)
@@ -1024,7 +1367,7 @@ func onThumbnail(ih iup.Ihandle) int {
break break
} }
if err := c.Thumbnail(file.Path, file.Stat); err != nil { if err := c.Thumbnail(file); err != nil {
iup.PostMessage(iup.GetHandle("dlg"), err.Error(), 0, 0) iup.PostMessage(iup.GetHandle("dlg"), err.Error(), 0, 0)
fmt.Println(err) fmt.Println(err)
@@ -1067,7 +1410,7 @@ func onCover(ih iup.Ihandle) int {
break break
} }
if err := c.Cover(file.Path, file.Stat); err != nil { if err := c.Cover(file); err != nil {
iup.PostMessage(iup.GetHandle("dlg"), err.Error(), 0, 0) iup.PostMessage(iup.GetHandle("dlg"), err.Error(), 0, 0)
fmt.Println(err) fmt.Println(err)
@@ -1131,7 +1474,7 @@ func onConvert(ih iup.Ihandle) int {
} }
} else { } else {
for _, file := range files { for _, file := range files {
if err := c.Convert(file.Path, file.Stat); err != nil { if err := c.Convert(file); err != nil {
convertErr(err) convertErr(err)
if errors.Is(err, context.Canceled) { if errors.Is(err, context.Canceled) {
break break
@@ -1149,7 +1492,7 @@ func onConvert(ih iup.Ihandle) int {
} }
func onOutputDirectory(ih iup.Ihandle) int { func onOutputDirectory(ih iup.Ihandle) int {
args, err := fileDlg("Output Directory", false, true) args, err := fileDlg("Output Directory", false, true, outputDirKey)
if err != nil { if err != nil {
iup.PostMessage(iup.GetHandle("dlg"), err.Error(), 0, 0) iup.PostMessage(iup.GetHandle("dlg"), err.Error(), 0, 0)
fmt.Println(err) fmt.Println(err)
@@ -1167,7 +1510,7 @@ func onOutputDirectory(ih iup.Ihandle) int {
} }
func onOutputFile(ih iup.Ihandle) int { func onOutputFile(ih iup.Ihandle) int {
name := saveDlg("Output File") name := saveDlg("Output File", outputDirKey)
if name != "" { if name != "" {
iup.GetHandle("OutFile").SetAttribute("VALUE", filepath.Base(name)) iup.GetHandle("OutFile").SetAttribute("VALUE", filepath.Base(name))
iup.GetHandle("OutDir").SetAttribute("VALUE", filepath.Dir(name)) iup.GetHandle("OutDir").SetAttribute("VALUE", filepath.Dir(name))
@@ -1200,7 +1543,7 @@ func onFilterChanged(ih iup.Ihandle) int {
return iup.DEFAULT return iup.DEFAULT
} }
func fileDlg(title string, multiple, directory bool) ([]string, error) { func fileDlg(title string, multiple, directory bool, dirKey string) ([]string, error) {
ret := make([]string, 0) ret := make([]string, 0)
dlg := iup.FileDlg() dlg := iup.FileDlg()
@@ -1213,11 +1556,12 @@ func fileDlg(title string, multiple, directory bool) ([]string, error) {
} }
dlg.SetAttributes(map[string]string{ dlg.SetAttributes(map[string]string{
"DIALOGTYPE": "OPEN", "DIALOGTYPE": "OPEN",
"MULTIPLEFILES": mf, "MULTIPLEFILES": mf,
"EXTFILTER": "Comic Files|*.rar;*.zip;*.7z;*.tar;*.cbr;*.cbz;*.cb7;*.cbt;*.pdf;*.epub;*.mobi;*.docx;*.pptx|", "MULTIVALUEPATH": "YES",
"FILTER": "*.cb*", // for Motif "EXTFILTER": "Comic Files|*.rar;*.zip;*.7z;*.tar;*.cbr;*.cbz;*.cb7;*.cbt;*.pdf;*.epub;*.mobi;*.docx;*.pptx|",
"TITLE": title, "FILTER": "*.cb*", // for Motif
"TITLE": title,
}) })
} else { } else {
dlg.SetAttributes(map[string]string{ dlg.SetAttributes(map[string]string{
@@ -1226,30 +1570,33 @@ func fileDlg(title string, multiple, directory bool) ([]string, error) {
}) })
} }
setStartDir(dlg, dirKey)
iup.Popup(dlg, iup.CENTERPARENT, iup.CENTERPARENT) iup.Popup(dlg, iup.CENTERPARENT, iup.CENTERPARENT)
if dlg.GetInt("STATUS") == 0 { if dlg.GetInt("STATUS") == 0 {
if !directory { switch {
value := dlg.GetAttribute("VALUE") case multiple:
sp := strings.Split(value, "|") // MULTIVALUEPATH makes each MULTIVALUE a full path (id 0 is the path), so a folder-spanning selection works.
count := dlg.GetInt("MULTIVALUECOUNT")
if len(sp) > 1 { if count > 1 {
for _, file := range sp[1 : len(sp)-1] { for i := 1; i < count; i++ {
ret = append(ret, filepath.Join(sp[0], file)) ret = append(ret, iup.GetAttributeId(dlg, "MULTIVALUE", i))
} }
} else { } else if value := dlg.GetAttribute("VALUE"); value != "" {
ret = append(ret, value) ret = append(ret, value)
} }
} else { default:
value := dlg.GetAttribute("VALUE") ret = append(ret, dlg.GetAttribute("VALUE"))
ret = append(ret, value)
} }
rememberDir(dlg, dirKey)
} }
return ret, nil return ret, nil
} }
func saveDlg(title string) string { func saveDlg(title, dirKey string) string {
dlg := iup.FileDlg() dlg := iup.FileDlg()
defer dlg.Destroy() defer dlg.Destroy()
@@ -1260,11 +1607,15 @@ func saveDlg(title string) string {
"TITLE": title, "TITLE": title,
}) })
setStartDir(dlg, dirKey)
iup.Popup(dlg, iup.CENTERPARENT, iup.CENTERPARENT) iup.Popup(dlg, iup.CENTERPARENT, iup.CENTERPARENT)
if dlg.GetInt("STATUS") == -1 { if dlg.GetInt("STATUS") == -1 {
return "" return ""
} }
rememberDir(dlg, dirKey)
return dlg.GetAttribute("VALUE") return dlg.GetAttribute("VALUE")
} }
+27 -13
View File
@@ -68,8 +68,8 @@ func main() {
if _, err := os.Stat(opts.OutDir); err != nil { if _, err := os.Stat(opts.OutDir); err != nil {
if err := os.MkdirAll(opts.OutDir, 0775); err != nil { if err := os.MkdirAll(opts.OutDir, 0775); err != nil {
fmt.Println(err) fmt.Println(err)
os.Exit(1)
} }
os.Exit(1)
} }
files, err := conv.Files(args) files, err := conv.Files(args)
@@ -142,14 +142,14 @@ func main() {
continue continue
case opts.Cover: case opts.Cover:
if err := conv.Cover(file.Path, file.Stat); err != nil { if err := conv.Cover(file); err != nil {
fmt.Println(err) fmt.Println(err)
os.Exit(1) os.Exit(1)
} }
continue continue
case opts.Thumbnail: case opts.Thumbnail:
if err = conv.Thumbnail(file.Path, file.Stat); err != nil { if err = conv.Thumbnail(file); err != nil {
fmt.Println(err) fmt.Println(err)
os.Exit(1) os.Exit(1)
} }
@@ -157,7 +157,7 @@ func main() {
continue continue
} }
if err := conv.Convert(file.Path, file.Stat); err != nil { if err := conv.Convert(file); err != nil {
fmt.Println(err) fmt.Println(err)
os.Exit(1) os.Exit(1)
} }
@@ -177,6 +177,7 @@ func parseFlags() (cbconvert.Options, []string) {
convert.BoolVar(&opts.Fit, "fit", false, "Best fit for required width and height") convert.BoolVar(&opts.Fit, "fit", false, "Best fit for required width and height")
convert.StringVar(&opts.Format, "format", "jpeg", "Image format, valid values are jpeg, png, tiff, bmp, webp, avif, jxl") convert.StringVar(&opts.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.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.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.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.Lossless, "lossless", false, "Lossless compression (webp, avif, jxl), ignores quality")
@@ -235,7 +236,7 @@ func parseFlags() (cbconvert.Options, []string) {
fmt.Fprintf(os.Stderr, "Usage: %s <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", "format", "archive", "quality", "effort", "lossless", "combine", "outfile", "filter", "no-cover", "no-rgb", order := []string{"width", "height", "fit", "format", "archive", "zip-level", "quality", "effort", "lossless", "combine", "outfile", "filter", "no-cover", "no-rgb",
"no-nonimage", "no-convert", "grayscale", "rotate", "brightness", "contrast", "suffix", "outdir", "size", "recursive", "quiet"} "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)
@@ -279,27 +280,27 @@ func parseFlags() (cbconvert.Options, []string) {
switch os.Args[1] { switch os.Args[1] {
case "convert": case "convert":
_ = convert.Parse(os.Args[2:]) operands := parseArgs(convert, os.Args[2:])
if !pipe { if !pipe {
args = convert.Args() args = operands
} }
case "cover": case "cover":
opts.Cover = true opts.Cover = true
_ = cover.Parse(os.Args[2:]) operands := parseArgs(cover, os.Args[2:])
if !pipe { if !pipe {
args = cover.Args() args = operands
} }
case "thumbnail": case "thumbnail":
opts.Thumbnail = true opts.Thumbnail = true
_ = thumbnail.Parse(os.Args[2:]) operands := parseArgs(thumbnail, os.Args[2:])
if !pipe { if !pipe {
args = thumbnail.Args() args = operands
} }
case "meta": case "meta":
opts.Meta = true opts.Meta = true
_ = meta.Parse(os.Args[2:]) operands := parseArgs(meta, os.Args[2:])
if !pipe { if !pipe {
args = meta.Args() args = operands
} }
case "version": case "version":
opts.Version = true opts.Version = true
@@ -314,6 +315,19 @@ func parseFlags() (cbconvert.Options, []string) {
return opts, args return opts, args
} }
// parseArgs parses flags interspersed with file/dir operands.
func parseArgs(fs *flag.FlagSet, args []string) []string {
var operands []string
_ = fs.Parse(args)
for fs.NArg() > 0 {
operands = append(operands, fs.Arg(0))
_ = fs.Parse(fs.Args()[1:])
}
return operands
}
// piped checks if we have piped stdin. // piped checks if we have piped stdin.
func piped() bool { func piped() bool {
f, err := os.Stdin.Stat() f, err := os.Stdin.Stat()