From 3c81595e08efa6deb34b2a88de0ebf39c35b68ef Mon Sep 17 00:00:00 2001 From: Milan Nikolic Date: Wed, 24 Jun 2026 04:37:38 +0200 Subject: [PATCH] Add Combine option, issue #21 --- cbconvert.go | 60 +++++++++++++ cbconvert_convert.go | 48 ++++------ cmd/cbconvert-gui/main.go | 184 ++++++++++++++++++++++++++------------ cmd/cbconvert/main.go | 17 +++- 4 files changed, 219 insertions(+), 90 deletions(-) diff --git a/cbconvert.go b/cbconvert.go index 4f6db73..a4c4021 100644 --- a/cbconvert.go +++ b/cbconvert.go @@ -27,6 +27,8 @@ type Options struct { Quality int // Encoder speed/effort, format-specific: webp method 0-6, avif speed 0-10, jxl effort 1-10; -1 uses the format default Effort int + // Combine all inputs into a single archive + Combine bool // Lossless enables lossless compression (webp, avif, jxl), ignores quality Lossless bool // Image width @@ -89,6 +91,8 @@ type Converter struct { Opts Options // Current working directory Workdir string + // Page name prefix, set per input when combining + prefix string // Number of files Nfiles int // Index of the current file @@ -499,6 +503,13 @@ func (c *Converter) Convert(fileName string, fileInfo os.FileInfo) error { defer cancel() c.OnCancel = cancel + c.prefix = "" + + var err error + c.Workdir, err = os.MkdirTemp(os.TempDir(), "cbc") + if err != nil { + return fmt.Errorf("%s: %w", fileName, err) + } switch { case fileInfo.IsDir(): @@ -523,3 +534,52 @@ func (c *Converter) Convert(fileName string, fileInfo os.FileInfo) error { return nil } + +// Combine merges multiple comic books into a single archive. +func (c *Converter) Combine(files []File) error { + if len(files) == 0 { + return nil + } + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + c.OnCancel = cancel + + var err error + c.Workdir, err = os.MkdirTemp(os.TempDir(), "cbc") + if err != nil { + return fmt.Errorf("Combine: %w", err) + } + + for i, file := range files { + c.CurrFile++ + c.prefix = fmt.Sprintf("%04d_", i+1) + + switch { + case file.Stat.IsDir(): + err = c.convertDirectory(ctx, file.Path) + case isDocument(file.Path): + err = c.convertDocument(ctx, file.Path) + case isArchive(file.Path): + err = c.convertArchive(ctx, file.Path) + } + + if err != nil { + return fmt.Errorf("%s: %w", file.Path, err) + } + } + + out := c.Opts.OutFile + if out == "" { + out = baseNoExt(files[0].Path) + "-combined" + } + + if err := c.archiveSave(out); err != nil { + return fmt.Errorf("Combine: %w", err) + } + + c.OnCancel = nil + + return nil +} diff --git a/cbconvert_convert.go b/cbconvert_convert.go index e71bc32..df22714 100644 --- a/cbconvert_convert.go +++ b/cbconvert_convert.go @@ -26,13 +26,6 @@ import ( // convertDocument converts PDF/EPUB document to CBZ. func (c *Converter) convertDocument(ctx context.Context, fileName string) error { - var err error - - c.Workdir, err = os.MkdirTemp(os.TempDir(), "cbc") - if err != nil { - return fmt.Errorf("convertDocument: %w", err) - } - doc, err := fitz.New(fileName) if err != nil { return fmt.Errorf("convertDocument: %w", err) @@ -77,13 +70,6 @@ func (c *Converter) convertDocument(ctx context.Context, fileName string) error // convertArchive converts archive to CBZ. func (c *Converter) convertArchive(ctx context.Context, fileName string) error { - var err error - - c.Workdir, err = os.MkdirTemp(os.TempDir(), "cbc") - if err != nil { - return fmt.Errorf("convertArchive: %w", err) - } - contents, err := c.archiveList(fileName) if err != nil { return fmt.Errorf("convertArchive: %w", err) @@ -137,7 +123,7 @@ func (c *Converter) convertArchive(ctx context.Context, fileName string) error { if isImage(pathName) { if c.Opts.NoConvert { - if err = copyFile(bytes.NewReader(data), filepath.Join(c.Workdir, filepath.Base(pathName))); err != nil { + if err = copyFile(bytes.NewReader(data), c.workPath(filepath.Base(pathName))); err != nil { return fmt.Errorf("convertArchive: %w", err) } @@ -145,7 +131,7 @@ func (c *Converter) convertArchive(ctx context.Context, fileName string) error { } if cover == pathName && c.Opts.NoCover { - if err = copyFile(bytes.NewReader(data), filepath.Join(c.Workdir, filepath.Base(pathName))); err != nil { + if err = copyFile(bytes.NewReader(data), c.workPath(filepath.Base(pathName))); err != nil { return fmt.Errorf("convertArchive: %w", err) } @@ -159,7 +145,7 @@ func (c *Converter) convertArchive(ctx context.Context, fileName string) error { } if c.Opts.NoRGB && !isGrayScale(img) { - if err = copyFile(bytes.NewReader(data), filepath.Join(c.Workdir, filepath.Base(pathName))); err != nil { + if err = copyFile(bytes.NewReader(data), c.workPath(filepath.Base(pathName))); err != nil { return fmt.Errorf("convertArchive: %w", err) } @@ -176,8 +162,8 @@ func (c *Converter) convertArchive(ctx context.Context, fileName string) error { return nil } - if !c.Opts.NoNonImage { - if err = copyFile(bytes.NewReader(data), filepath.Join(c.Workdir, filepath.Base(pathName))); err != nil { + if c.prefix == "" && !c.Opts.NoNonImage { + if err = copyFile(bytes.NewReader(data), c.workPath(filepath.Base(pathName))); err != nil { return fmt.Errorf("convertArchive: %w", err) } } @@ -199,13 +185,6 @@ func (c *Converter) convertArchive(ctx context.Context, fileName string) error { // convertDirectory converts directory to CBZ. func (c *Converter) convertDirectory(ctx context.Context, dirPath string) error { - var err error - - c.Workdir, err = os.MkdirTemp(os.TempDir(), "cbc") - if err != nil { - return fmt.Errorf("convertDirectory: %w", err) - } - contents, err := imagesFromPath(dirPath) if err != nil { return fmt.Errorf("convertDirectory: %w", err) @@ -232,8 +211,8 @@ func (c *Converter) convertDirectory(ctx context.Context, dirPath string) error return fmt.Errorf("convertDirectory: %w", err) } - if isNonImage(img) && !c.Opts.NoNonImage { - if err = copyFile(file, filepath.Join(c.Workdir, filepath.Base(img))); err != nil { + if isNonImage(img) && c.prefix == "" && !c.Opts.NoNonImage { + if err = copyFile(file, c.workPath(filepath.Base(img))); err != nil { return fmt.Errorf("convertDirectory: %w", err) } @@ -244,7 +223,7 @@ func (c *Converter) convertDirectory(ctx context.Context, dirPath string) error continue } else if isImage(img) { if c.Opts.NoConvert { - if err = copyFile(file, filepath.Join(c.Workdir, filepath.Base(img))); err != nil { + if err = copyFile(file, c.workPath(filepath.Base(img))); err != nil { return fmt.Errorf("convertDirectory: %w", err) } @@ -262,7 +241,7 @@ func (c *Converter) convertDirectory(ctx context.Context, dirPath string) error } if c.Opts.NoRGB && !isGrayScale(i) { - if err = copyFile(file, filepath.Join(c.Workdir, filepath.Base(img))); err != nil { + if err = copyFile(file, c.workPath(filepath.Base(img))); err != nil { return fmt.Errorf("convertDirectory: %w", err) } @@ -293,6 +272,11 @@ func (c *Converter) convertDirectory(ctx context.Context, dirPath string) error return nil } +// workPath returns the path of name inside the workdir, with the combine prefix applied. +func (c *Converter) workPath(name string) string { + return filepath.Join(c.Workdir, c.prefix+name) +} + // imageConvert converts image.Image. func (c *Converter) imageConvert(ctx context.Context, img image.Image, index int, pathName string) error { err := ctx.Err() @@ -312,9 +296,9 @@ func (c *Converter) imageConvert(ctx context.Context, img image.Image, index int var fileName string if pathName != "" { - fileName = filepath.Join(c.Workdir, fmt.Sprintf("%s.%s", baseNoExt(pathName), ext)) + fileName = c.workPath(fmt.Sprintf("%s.%s", baseNoExt(pathName), ext)) } else { - fileName = filepath.Join(c.Workdir, fmt.Sprintf("%03d.%s", index, ext)) + fileName = c.workPath(fmt.Sprintf("%03d.%s", index, ext)) } img = c.imageTransform(img) diff --git a/cmd/cbconvert-gui/main.go b/cmd/cbconvert-gui/main.go index 5826622..4c42822 100644 --- a/cmd/cbconvert-gui/main.go +++ b/cmd/cbconvert-gui/main.go @@ -69,8 +69,6 @@ func main() { iup.Open() defer iup.Close() - iup.SetGlobal("UTF8MODE", "YES") - iup.SetGlobal("UTF8MODE_FILE", "YES") iup.SetGlobal("AUTODARKMODE", "YES") img, _ := png.Decode(bytes.NewReader(appLogo)) @@ -98,13 +96,7 @@ func main() { dlg.SetCallback("THEMECHANGED_CB", iup.ThemeChangedFunc(func(ih iup.Ihandle, darkMode int) int { t := iup.GetHandle("Table") - if darkMode == 1 { - t.SetAttribute("EVENROWCOLOR", "#3A3A3A") - t.SetAttribute("ODDROWCOLOR", "#2D2D2D") - } else { - t.SetAttribute("EVENROWCOLOR", "#F0F0F0") - t.SetAttribute("ODDROWCOLOR", "#FFFFFF") - } + tableRowColors(t, darkMode == 1) t.SetAttribute("REDRAW", "YES") return iup.DEFAULT @@ -162,6 +154,10 @@ func options() cbconvert.Options { opts.Effort = -1 } opts.Lossless = iup.GetHandle("Lossless").GetAttribute("VALUE") == "ON" + opts.Combine = iup.GetHandle("Combine").GetAttribute("VALUE") == "ON" + if opts.Combine { + opts.OutFile = iup.GetHandle("OutFile").GetAttribute("VALUE") + } opts.Grayscale = iup.GetHandle("Grayscale").GetAttribute("VALUE") == "ON" opts.Brightness = iup.GetHandle("Brightness").GetInt("VALUE") opts.Contrast = iup.GetHandle("Contrast").GetInt("VALUE") @@ -244,6 +240,12 @@ func setActive() { } else { iup.GetHandle("Fit").SetAttribute("ACTIVE", "NO") } + + if opts.Combine { + iup.GetHandle("VboxOutFile").SetAttribute("ACTIVE", "YES") + } else { + iup.GetHandle("VboxOutFile").SetAttribute("ACTIVE", "NO") + } } func setEffort(format string) { @@ -291,6 +293,16 @@ func layout() iup.Ihandle { ) } +// tableRowColors sets the alternating row colors for dark or light mode. +func tableRowColors(t iup.Ihandle, dark bool) { + even, odd := "#F0F0F0", "#FFFFFF" + if dark { + even, odd = "#3A3A3A", "#2D2D2D" + } + t.SetAttribute("EVENROWCOLOR", even) + t.SetAttribute("ODDROWCOLOR", odd) +} + func list() iup.Ihandle { t := iup.Table().SetHandle("Table") t.SetAttributes(map[string]string{ @@ -313,13 +325,7 @@ func list() iup.Ihandle { "ALTERNATECOLOR": "YES", }) - if iup.GetGlobal("DARKMODE") == "YES" && iup.GetGlobal("AUTODARKMODE") == "YES" { - t.SetAttribute("EVENROWCOLOR", "#3A3A3A") - t.SetAttribute("ODDROWCOLOR", "#2D2D2D") - } else { - t.SetAttribute("EVENROWCOLOR", "#F0F0F0") - t.SetAttribute("ODDROWCOLOR", "#FFFFFF") - } + tableRowColors(t, iup.GetGlobal("DARKMODE") == "YES" && iup.GetGlobal("AUTODARKMODE") == "YES") t.SetCallback("ENTERITEM_CB", iup.EnterItemFunc(func(ih iup.Ihandle, lin, col int) int { index = lin - 1 @@ -489,34 +495,56 @@ func tabs() iup.Ihandle { iup.Space().SetAttributes("EXPAND=HORIZONTAL"), ).SetHandle("VboxInput").SetAttributes("NGAP=10") - vboxOutput := iup.Vbox( + vboxOutput := iup.Hbox( iup.Vbox( - iup.Label("Output Directory:"), - iup.Text().SetAttributes("VISIBLECOLUMNS=16, MINSIZE=100x").SetHandle("OutDir"). - SetCallback("VALUECHANGED_CB", iup.ValueChangedFunc(func(ih iup.Ihandle) int { - setActive() + iup.Vbox( + iup.Label("Output Directory:"), + iup.Text().SetAttributes("VISIBLECOLUMNS=16, MINSIZE=100x").SetHandle("OutDir"). + SetCallback("VALUECHANGED_CB", iup.ValueChangedFunc(func(ih iup.Ihandle) int { + setActive() - return iup.DEFAULT - })), - iup.Space().SetAttribute("SIZE", "5x0"), - iup.Button("Browse...").SetAttributes("PADDING=DEFAULTBUTTONPADDING"). - SetCallback("ACTION", iup.ActionFunc(onOutputDirectory)), - ), + return iup.DEFAULT + })), + iup.Space().SetAttribute("SIZE", "5x0"), + iup.Button("Browse...").SetAttributes("PADDING=DEFAULTBUTTONPADDING"). + SetCallback("ACTION", iup.ActionFunc(onOutputDirectory)), + ), + iup.Vbox( + iup.Label("Add Suffix to Output File:"), + iup.Text().SetAttributes("VISIBLECOLUMNS=16, MINSIZE=100x").SetHandle("Suffix"). + SetAttribute("TIP", "Add suffix to filename, i.e. filename_suffix.cbz"), + ), + iup.Vbox( + iup.Label("Archive Format:"), + iup.List().SetAttributes(map[string]string{ + "DROPDOWN": "YES", + "VALUE": "1", + "1": "ZIP", + "2": "TAR", + }).SetHandle("Archive"), + ), + ).SetAttributes("NGAP=10"), + iup.Space().SetAttribute("SIZE", "15"), iup.Vbox( - iup.Label("Add Suffix to Output File:"), - iup.Text().SetAttributes("VISIBLECOLUMNS=16, MINSIZE=100x").SetHandle("Suffix"). - SetAttribute("TIP", "Add suffix to filename, i.e. filename_suffix.cbz"), - ), - iup.Vbox( - iup.Label("Archive Format:"), - iup.List().SetAttributes(map[string]string{ - "DROPDOWN": "YES", - "VALUE": "1", - "1": "ZIP", - "2": "TAR", - }).SetHandle("Archive"), - ), - ).SetHandle("VboxOutput").SetAttributes("NGAP=10") + iup.Vbox( + iup.Toggle(" Combine into single file").SetHandle("Combine"). + SetAttributes(`TIP="Merge all listed files into one archive"`). + SetCallback("VALUECHANGED_CB", iup.ValueChangedFunc(func(ih iup.Ihandle) int { + setActive() + + return iup.DEFAULT + })), + ), + iup.Vbox( + iup.Label("Output File:"), + iup.Text().SetAttributes("VISIBLECOLUMNS=16, MINSIZE=100x").SetHandle("OutFile"). + SetAttribute("TIP", "Combined file name (default: first input + -combined)"), + iup.Space().SetAttribute("SIZE", "5x0"), + iup.Button("Browse...").SetAttributes("PADDING=DEFAULTBUTTONPADDING"). + SetCallback("ACTION", iup.ActionFunc(onOutputFile)), + ).SetHandle("VboxOutFile"), + ).SetAttributes("NGAP=10"), + ).SetHandle("VboxOutput") vboxImage := iup.Hbox( iup.Vbox( @@ -1079,25 +1107,38 @@ func onConvert(ih iup.Ihandle) int { return iup.DEFAULT })) + convertErr := func(err error) { + if errors.Is(err, context.Canceled) { + if err := os.RemoveAll(conv.Workdir); err != nil { + fmt.Println(err) + } + + return + } + + iup.PostMessage(iup.GetHandle("dlg"), err.Error(), 0, 0) + fmt.Println(err) + + if err := os.RemoveAll(conv.Workdir); err != nil { + fmt.Println(err) + } + } + go func(c *cbconvert.Converter) { - for _, file := range files { - if err := c.Convert(file.Path, file.Stat); err != nil { - if errors.Is(err, context.Canceled) { - if err := os.RemoveAll(c.Workdir); err != nil { - fmt.Println(err) + if c.Opts.Combine { + if err := c.Combine(files); err != nil { + convertErr(err) + } + } else { + for _, file := range files { + if err := c.Convert(file.Path, file.Stat); err != nil { + convertErr(err) + if errors.Is(err, context.Canceled) { + break } - break + continue } - - iup.PostMessage(iup.GetHandle("dlg"), err.Error(), 0, 0) - fmt.Println(err) - - if err := os.RemoveAll(c.Workdir); err != nil { - fmt.Println(err) - } - - continue } } @@ -1125,6 +1166,17 @@ func onOutputDirectory(ih iup.Ihandle) int { return iup.DEFAULT } +func onOutputFile(ih iup.Ihandle) int { + name := saveDlg("Output File") + if name != "" { + iup.GetHandle("OutFile").SetAttribute("VALUE", filepath.Base(name)) + iup.GetHandle("OutDir").SetAttribute("VALUE", filepath.Dir(name)) + setActive() + } + + return iup.DEFAULT +} + func onFilterChanged(ih iup.Ihandle) int { switch ih.GetInt("VALUE") { case 1: @@ -1196,3 +1248,23 @@ func fileDlg(title string, multiple, directory bool) ([]string, error) { return ret, nil } + +func saveDlg(title string) string { + dlg := iup.FileDlg() + defer dlg.Destroy() + + dlg.SetAttributes(map[string]string{ + "DIALOGTYPE": "SAVE", + "EXTFILTER": "Comic Files|*.cbz;*.cbt|", + "FILTER": "*.cb*", // for Motif + "TITLE": title, + }) + + iup.Popup(dlg, iup.CENTERPARENT, iup.CENTERPARENT) + + if dlg.GetInt("STATUS") == -1 { + return "" + } + + return dlg.GetAttribute("VALUE") +} diff --git a/cmd/cbconvert/main.go b/cmd/cbconvert/main.go index e2323bd..3686140 100644 --- a/cmd/cbconvert/main.go +++ b/cmd/cbconvert/main.go @@ -114,6 +114,17 @@ func main() { } } + if opts.Combine { + if err := conv.Combine(files); err != nil { + fmt.Println(err) + os.Exit(1) + } + + fmt.Fprintf(os.Stderr, "\r") + + return + } + for _, file := range files { switch { case opts.Meta: @@ -169,6 +180,8 @@ func parseFlags() (cbconvert.Options, []string) { 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") @@ -222,7 +235,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", "format", "archive", "quality", "effort", "lossless", "filter", "no-cover", "no-rgb", + order := []string{"width", "height", "fit", "format", "archive", "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) @@ -230,7 +243,7 @@ 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", "format", "quality", "effort", "lossless", "filter", "outdir", "size", "recursive", "quiet"} + order = []string{"width", "height", "fit", "format", "quality", "effort", "lossless", "combine", "outfile", "filter", "outdir", "size", "recursive", "quiet"} for _, name := range order { f := cover.Lookup(name) fmt.Fprintf(os.Stderr, " --%s\n \t", f.Name)