3 Commits
Author SHA1 Message Date
Milan Nikolic 4e2f78026d Destroy current image 2026-06-25 11:26:03 +02:00
Milan Nikolic e1134cd902 Add page spin to preview other pages 2026-06-25 11:03:27 +02:00
Milan Nikolic f289c9cd06 Add profiles support to cli app 2026-06-25 10:59:12 +02:00
10 changed files with 894 additions and 176 deletions
+23 -10
View File
@@ -43,7 +43,7 @@ type Options struct {
NoUpscale bool
// Document rendering resolution in DPI (PDF, EPUB, etc.); 0 uses the default
DPI int
// 0=NearestNeighbor, 1=Box, 2=Linear, 3=MitchellNetravali, 4=CatmullRom, 6=Gaussian, 7=Lanczos
// 0=NearestNeighbor, 1=Box, 2=Linear, 3=MitchellNetravali, 4=CatmullRom, 5=Gaussian, 6=Lanczos
Filter int
// Do not convert the cover image
NoCover bool
@@ -410,7 +410,7 @@ func (c *Converter) Thumbnail(file File) error {
if c.Opts.Width > 0 || c.Opts.Height > 0 {
cover = c.resizeFit(cover)
} else {
cover = resize(cover, 256, 0, filters[c.Opts.Filter])
cover = resize(cover, 256, 0, resampleFilter(c.Opts.Filter))
}
var buf bytes.Buffer
@@ -542,15 +542,30 @@ func (c *Converter) Meta(fileName string) (any, error) {
return "", nil
}
// Preview returns image preview.
// Preview returns the cover as an image preview.
func (c *Converter) Preview(fileName string, fileInfo os.FileInfo, width, height int) (Image, error) {
var img Image
i, err := c.coverImage(fileName, fileInfo)
if err != nil {
return img, fmt.Errorf("%s: %w", fileName, err)
return Image{}, fmt.Errorf("%s: %w", fileName, err)
}
return c.previewImage(fileName, i, width, height)
}
// PreviewPage returns the page-th image (0-based) as an image preview.
func (c *Converter) PreviewPage(fileName string, fileInfo os.FileInfo, page, width, height int) (Image, error) {
i, err := c.pageImage(fileName, fileInfo, page)
if err != nil {
return Image{}, fmt.Errorf("%s: %w", fileName, err)
}
return c.previewImage(fileName, i, width, height)
}
// previewImage applies the configured transforms and fits the result into width x height.
func (c *Converter) previewImage(fileName string, i image.Image, width, height int) (Image, error) {
var img Image
i = c.imageTransform(i)
var w bytes.Buffer
@@ -563,15 +578,13 @@ func (c *Converter) Preview(fileName string, fileInfo os.FileInfo, width, height
img.Height = i.Bounds().Dy()
img.SizeHuman = humanize.IBytes(uint64(len(w.Bytes())))
r := bytes.NewReader(w.Bytes())
dec, err := c.imageDecode(r)
dec, err := c.imageDecode(bytes.NewReader(w.Bytes()))
if err != nil {
return img, fmt.Errorf("%s: %w", fileName, err)
}
if width != 0 && height != 0 {
dec = fit(dec, width, height, filters[c.Opts.Filter])
dec = fit(dec, width, height, resampleFilter(c.Opts.Filter))
}
img.Image = dec
+126
View File
@@ -85,6 +85,132 @@ func (c *Converter) coverDirectory(dir string) (image.Image, error) {
return img, nil
}
// pageArchive extracts the page-th image (natural reading order) from an archive.
func (c *Converter) pageArchive(fileName string, page int) (image.Image, error) {
contents, err := c.archiveList(fileName)
if err != nil {
return nil, fmt.Errorf("pageArchive: %w", err)
}
images := imagesFromSlice(contents)
sort.Sort(sortorder.Natural(images))
if page < 0 || page >= len(images) {
return nil, fmt.Errorf("pageArchive: page %d out of range (%d pages)", page+1, len(images))
}
data, err := c.archiveFile(fileName, images[page])
if err != nil {
return nil, fmt.Errorf("pageArchive: %w", err)
}
img, err := c.imageDecode(bytes.NewReader(data))
if err != nil {
return nil, fmt.Errorf("pageArchive: %w", err)
}
return img, nil
}
// pageDocument extracts the page-th rendered page from a document.
func (c *Converter) pageDocument(fileName string, page int) (image.Image, error) {
doc, err := fitz.New(fileName)
if err != nil {
return nil, fmt.Errorf("pageDocument: %w", err)
}
defer doc.Close()
if page < 0 || page >= doc.NumPage() {
return nil, fmt.Errorf("pageDocument: page %d out of range (%d pages)", page+1, doc.NumPage())
}
img, err := doc.ImageDPI(page, c.renderDPI())
if err != nil {
return nil, fmt.Errorf("pageDocument: %w", err)
}
return img, nil
}
// pageDirectory extracts the page-th image (natural reading order) from a directory.
func (c *Converter) pageDirectory(dir string, page int) (image.Image, error) {
contents, err := imagesFromPath(dir)
if err != nil {
return nil, fmt.Errorf("pageDirectory: %w", err)
}
images := imagesFromSlice(contents)
sort.Sort(sortorder.Natural(images))
if page < 0 || page >= len(images) {
return nil, fmt.Errorf("pageDirectory: page %d out of range (%d pages)", page+1, len(images))
}
file, err := os.Open(images[page])
if err != nil {
return nil, fmt.Errorf("pageDirectory: %w", err)
}
defer file.Close()
img, err := c.imageDecode(file)
if err != nil {
return nil, fmt.Errorf("pageDirectory: %w", err)
}
return img, nil
}
// pageImage returns the page-th image of a comic file, document or directory.
func (c *Converter) pageImage(fileName string, fileInfo os.FileInfo, page int) (image.Image, error) {
var err error
var img image.Image
switch {
case fileInfo.IsDir():
img, err = c.pageDirectory(fileName, page)
case isDocument(fileName):
img, err = c.pageDocument(fileName, page)
case isArchive(fileName):
img, err = c.pageArchive(fileName, page)
}
if err != nil {
return nil, fmt.Errorf("pageImage: %w", err)
}
return img, nil
}
// PageCount returns the number of pages (images) in a comic file, document or directory.
func (c *Converter) PageCount(fileName string, fileInfo os.FileInfo) (int, error) {
switch {
case fileInfo.IsDir():
contents, err := imagesFromPath(fileName)
if err != nil {
return 0, fmt.Errorf("PageCount: %w", err)
}
return len(imagesFromSlice(contents)), nil
case isDocument(fileName):
doc, err := fitz.New(fileName)
if err != nil {
return 0, fmt.Errorf("PageCount: %w", err)
}
defer doc.Close()
return doc.NumPage(), nil
case isArchive(fileName):
contents, err := c.archiveList(fileName)
if err != nil {
return 0, fmt.Errorf("PageCount: %w", err)
}
return len(imagesFromSlice(contents)), nil
}
return 0, nil
}
// coverName returns the filename that is the most likely to be the cover.
func (c *Converter) coverName(images []string) string {
if len(images) == 0 {
+11 -2
View File
@@ -38,6 +38,15 @@ var filters = map[int]transform.ResampleFilter{
lanczos: transform.Lanczos,
}
// resampleFilter returns the resample filter for index i, falling back to Linear for an unknown index.
func resampleFilter(i int) transform.ResampleFilter {
if f, ok := filters[i]; ok {
return f
}
return filters[linear]
}
func resize(img image.Image, width, height int, filter transform.ResampleFilter) *image.RGBA {
dstW, dstH := width, height
@@ -96,14 +105,14 @@ func withinBounds(img image.Image, width, height int) bool {
// resizeFit resizes img to the configured width/height, honoring Fit and NoUpscale.
func (c *Converter) resizeFit(img image.Image) image.Image {
if c.Opts.Fit {
return fit(img, c.Opts.Width, c.Opts.Height, filters[c.Opts.Filter])
return fit(img, c.Opts.Width, c.Opts.Height, resampleFilter(c.Opts.Filter))
}
if c.Opts.NoUpscale && withinBounds(img, c.Opts.Width, c.Opts.Height) {
return img
}
return resize(img, c.Opts.Width, c.Opts.Height, filters[c.Opts.Filter])
return resize(img, c.Opts.Width, c.Opts.Height, resampleFilter(c.Opts.Filter))
}
func rotate(img image.Image, angle float64) *image.RGBA {
+34
View File
@@ -211,6 +211,40 @@ func TestConvertDPI(t *testing.T) {
}
}
func TestPreviewPage(t *testing.T) {
for _, name := range []string{"testdata/test.cbz", "testdata/test.pdf"} {
conv := New(NewOptions())
files, err := conv.Files([]string{name})
if err != nil {
t.Fatal(err)
}
if len(files) != 1 {
t.Fatalf("%s: expected 1 file, got %d", name, len(files))
}
file := files[0]
n, err := conv.PageCount(file.Path, file.Stat)
if err != nil {
t.Fatal(err)
}
if n < 2 {
t.Fatalf("%s: expected >= 2 pages, got %d", name, n)
}
for _, page := range []int{0, 1, n - 1} {
img, err := conv.PreviewPage(file.Path, file.Stat, page, 0, 0)
if err != nil || img.Image == nil {
t.Fatalf("%s: page %d: img=%v err=%v", name, page, img.Image, err)
}
}
if _, err := conv.PreviewPage(file.Path, file.Stat, n, 0, 0); err == nil {
t.Errorf("%s: page %d (out of range) should error", name, n)
}
}
}
func TestConvertResize(t *testing.T) {
tmpDir, err := os.MkdirTemp(os.TempDir(), "cbc")
if err != nil {
+117 -48
View File
@@ -7,9 +7,10 @@ import (
"os"
"path/filepath"
"slices"
"sort"
"strconv"
"strings"
"github.com/fvbommel/sortorder"
"github.com/gen2brain/cbconvert"
"github.com/gen2brain/iup-go/iup"
)
@@ -24,7 +25,7 @@ func selectRow(i int) {
iup.GetHandle("Table").SetAttribute("FOCUSCELL", fmt.Sprintf("%d:1", i+1))
}
// onSort re-syncs the files slice to the table's displayed order after a sort, so rows keep mapping to the right file.
// onSort re-syncs the files slice to the table's displayed order after a sort.
func onSort(ih iup.Ihandle, col int) int {
n := len(files)
if n < 2 {
@@ -73,16 +74,49 @@ func onSort(ih iup.Ihandle, col int) int {
return iup.DEFAULT
}
// appendFile adds a file as a new row to the table and the files slice.
func appendFile(file cbconvert.File) {
files = append(files, file)
// addFiles appends files, natural-sorts the list, and rebuilds the table.
func addFiles(fs []cbconvert.File) {
if len(fs) == 0 {
return
}
wasEmpty := len(files) == 0
var selPath string
if index >= 0 && index < len(files) {
selPath = files[index].Path
}
files = append(files, fs...)
sort.Slice(files, func(i, j int) bool {
return sortorder.NaturalLess(files[i].Name, files[j].Name)
})
t := iup.GetHandle("Table")
lin := len(files)
t.SetAttribute("NUMLIN", strconv.Itoa(lin))
iup.SetAttributeId2(t, "", lin, 1, file.Name)
iup.SetAttributeId2(t, "", lin, 2, cbconvert.FileType(file.Path))
iup.SetAttributeId2(t, "", lin, 3, strconv.FormatFloat(float64(file.Stat.Size())/(1024*1024), 'f', 2, 64))
t.SetAttribute("NUMLIN", strconv.Itoa(len(files)))
for i, f := range files {
lin := i + 1
iup.SetAttributeId2(t, "", lin, 1, f.Name)
iup.SetAttributeId2(t, "", lin, 2, cbconvert.FileType(f.Path))
iup.SetAttributeId2(t, "", lin, 3, strconv.FormatFloat(float64(f.Stat.Size())/(1024*1024), 'f', 2, 64))
}
if wasEmpty {
selectRow(0)
setActive()
previewPost()
return
}
index = -1
for i, f := range files {
if f.Path == selPath {
selectRow(i)
break
}
}
setActive()
}
func previewPost() {
@@ -90,30 +124,77 @@ func previewPost() {
return
}
width, height := previewSize()
iup.GetHandle("Loading").SetAttributes("VISIBLE=YES, START=YES")
if strings.ToLower(iup.GetGlobal("DRIVER")) == "motif" {
iup.GetHandle("Preview").SetAttribute("IMAGE", "")
file := files[index]
// On a new file, fetch the count first; the Page POSTMESSAGE handler clamps previewPage and renders.
if file.Path != previewPath {
previewPath = file.Path
iup.GetHandle("Loading").SetAttributes("VISIBLE=YES, START=YES")
go pageCountPost(file)
return
}
previewRender()
}
// previewRenderSize is the size the cover is rendered at.
const previewRenderSize = 1200
// previewRender renders the current file at previewPage off the UI thread.
func previewRender() {
if index == -1 || len(files) == 0 {
return
}
file := files[index]
iup.GetHandle("Loading").SetAttributes("VISIBLE=YES, START=YES")
opts := options()
page := previewPage
go func(opts cbconvert.Options) {
conv := cbconvert.New(opts)
var s string
file := files[index]
img, err := conv.Preview(file.Path, file.Stat, width, height)
img, err := conv.PreviewPage(file.Path, file.Stat, page, previewRenderSize, previewRenderSize)
if err != nil {
s = err.Error()
fmt.Println(err)
}
iup.PostMessage(iup.GetHandle("Preview"), s, 0, img)
iup.PostMessage(iup.GetHandle("Preview"), s, page, img)
}(opts)
}
// pageCountPost computes the file's page count off the UI thread and posts it, tagged with the path, to the Page spin.
func pageCountPost(file cbconvert.File) {
n, err := cbconvert.New(cbconvert.NewOptions()).PageCount(file.Path, file.Stat)
if err != nil || n < 1 {
n = 1
}
iup.PostMessage(iup.GetHandle("Page"), file.Path, n, nil)
}
// onPageChanged re-renders the preview for the spin's page; dedupes so spin and typing don't both fire.
func onPageChanged() int {
page := iup.GetHandle("Page").GetInt("VALUE") - 1
if page < 0 {
page = 0
}
if page == previewPage {
return iup.DEFAULT
}
previewPage = page
previewRender()
return iup.DEFAULT
}
func onAddFiles(ih iup.Ihandle) int {
args, err := fileDlg("Add Files", true, false, inputDirKey)
if err != nil {
@@ -134,21 +215,7 @@ func onAddFiles(ih iup.Ihandle) int {
return iup.DEFAULT
}
wasEmpty := len(files) == 0
for _, file := range fs {
appendFile(file)
}
if wasEmpty && len(files) > 0 {
selectRow(0)
}
setActive()
if wasEmpty {
previewPost()
}
addFiles(fs)
}
return iup.DEFAULT
@@ -174,21 +241,7 @@ func onAddDir(ih iup.Ihandle) int {
return iup.DEFAULT
}
wasEmpty := len(files) == 0
for _, file := range fs {
appendFile(file)
}
if wasEmpty && len(files) > 0 {
selectRow(0)
}
setActive()
if wasEmpty {
previewPost()
}
addFiles(fs)
}
return iup.DEFAULT
@@ -207,16 +260,32 @@ func onRemove(ih iup.Ihandle) int {
}
setActive()
previewPost()
if len(files) == 0 {
clearPreview()
} else {
previewPost()
}
return iup.DEFAULT
}
// clearPreview resets the preview state and repaints the canvas to its empty state.
func clearPreview() {
iup.Destroy(iup.GetHandle("cover"))
hasCover = false
previewPath = ""
previewPage = 0
iup.GetHandle("PreviewInfo").SetAttribute("TITLE", "")
iup.Update(iup.GetHandle("Preview"))
}
func onRemoveAll(ih iup.Ihandle) int {
index = -1
files = make([]cbconvert.File, 0)
iup.GetHandle("Table").SetAttribute("NUMLIN", "0")
clearPreview()
setActive()
return iup.DEFAULT
+5 -10
View File
@@ -33,6 +33,10 @@ var (
activeConv *cbconvert.Converter
busy bool
previewPage int // 0-based page shown in the preview
previewPath string // path of the file whose page range is loaded
hasCover bool // whether a cover image is loaded for the preview canvas
)
func init() {
@@ -78,7 +82,7 @@ func main() {
img, _ := png.Decode(bytes.NewReader(appLogo))
iup.ImageFromImage(img).SetHandle("logo")
dlg := iup.Dialog(layout()).SetAttributes(fmt.Sprintf(`TITLE="CBconvert %s", ICON=logo, SHRINK=YES`, appVersion)).SetHandle("dlg")
dlg := iup.Dialog(layout()).SetAttributes(fmt.Sprintf(`TITLE="CBconvert %s", ICON=logo`, appVersion)).SetHandle("dlg")
dlg.SetCallback("POSTMESSAGE_CB", iup.PostMessageFunc(func(ih iup.Ihandle, s string, i int, p any) int {
sp := strings.Split(s, ": ")
@@ -89,15 +93,6 @@ func main() {
return iup.DEFAULT
}))
dlg.SetCallback("RESIZE_CB", iup.ResizeFunc(func(ih iup.Ihandle, width, height int) int {
iup.GetHandle("Preview").SetAttribute("IMAGE", "logo")
iup.Refresh(ih)
previewPost()
return iup.DEFAULT
}))
dlg.SetCallback("THEMECHANGED_CB", iup.ThemeChangedFunc(func(ih iup.Ihandle, darkMode int) int {
t := iup.GetHandle("Table")
tableRowColors(t, darkMode == 1)
+6
View File
@@ -10,6 +10,12 @@ func setActive() {
opts := options()
count := iup.GetHandle("Table").GetInt("NUMLIN")
if count > 0 && index != -1 {
iup.GetHandle("PageBox").SetAttribute("VISIBLE", "YES")
} else {
iup.GetHandle("PageBox").SetAttribute("VISIBLE", "NO")
}
if count == 0 {
iup.GetHandle("Remove").SetAttribute("ACTIVE", "NO")
iup.GetHandle("RemoveAll").SetAttribute("ACTIVE", "NO")
+106 -51
View File
@@ -4,6 +4,7 @@ import (
"bytes"
"fmt"
"image/gif"
"math"
"net/url"
"strconv"
"strings"
@@ -58,7 +59,7 @@ func list() iup.Ihandle {
"TITLE1": "Title",
"TITLE2": "Type",
"TITLE3": "Size (MiB)",
"WIDTH1": "150",
"WIDTH1": "300",
"WIDTH2": "50",
"WIDTH3": "100",
"ALIGNMENT2": "ACENTER",
@@ -102,21 +103,7 @@ func list() iup.Ihandle {
return iup.DEFAULT
}
wasEmpty := len(files) == 0
for _, file := range fs {
appendFile(file)
}
if wasEmpty && len(files) > 0 {
selectRow(0)
}
setActive()
if wasEmpty {
previewPost()
}
addFiles(fs)
return iup.DEFAULT
}))
@@ -124,49 +111,116 @@ func list() iup.Ihandle {
return iup.Vbox(t)
}
func previewSize() (int, int) {
var width, height int
sp := strings.Split(iup.GetHandle("Preview").GetAttribute("RASTERSIZE"), "x")
if len(sp) == 2 {
width, _ = strconv.Atoi(sp[0])
height, _ = strconv.Atoi(sp[1])
}
return width, height
}
func preview() iup.Ihandle {
return iup.Frame(
iup.Vbox(
iup.Label("").SetAttributes("EXPAND=YES, ALIGNMENT=ACENTER, MINSIZE=400x, IMAGE=cover").SetHandle("Preview").
SetCallback("POSTMESSAGE_CB", iup.PostMessageFunc(func(ih iup.Ihandle, s string, i int, p any) int {
img := p.(cbconvert.Image)
iup.GetHandle("Loading").SetAttributes("VISIBLE=NO, STOP=YES")
if img.Image != nil && len(s) == 0 {
iup.Destroy(iup.GetHandle("cover"))
iup.ImageFromImage(img.Image).SetHandle("cover")
ih.SetAttribute("IMAGE", "cover")
iup.GetHandle("PreviewInfo").SetAttribute("TITLE", fmt.Sprintf("%s (%dx%d)", img.SizeHuman, img.Width, img.Height))
} else {
ih.SetAttribute("IMAGE", "logo")
iup.GetHandle("PreviewInfo").SetAttribute("TITLE", "")
sp := strings.Split(s, ": ")
if len(sp) > 1 {
iup.MessageError(ih, fmt.Sprintf("%s\n\n%s", sp[0], strings.Join(sp[1:], ": ")))
}
}
return iup.DEFAULT
})),
iup.Canvas().SetAttributes("EXPAND=YES, MINSIZE=400x, BORDER=NO").SetHandle("Preview").
SetCallback("ACTION", iup.ActionFunc(drawPreview)).
SetCallback("POSTMESSAGE_CB", iup.PostMessageFunc(previewMessage)),
iup.Label("").SetAttributes("EXPAND=HORIZONTAL, ALIGNMENT=ACENTER").SetHandle("PreviewInfo"),
),
)
}
// drawPreview draws the cover scaled to fit, else the logo centered.
func drawPreview(ih iup.Ihandle) int {
iup.DrawBegin(ih)
defer iup.DrawEnd(ih)
cw, ch := iup.DrawGetSize(ih)
iup.DrawParentBackground(ih)
name := "logo"
if hasCover {
name = "cover"
}
iw, ihh, _ := iup.DrawGetImageInfo(name)
if iw <= 0 || ihh <= 0 {
return iup.DEFAULT
}
dw, dh := iw, ihh
if hasCover {
s := math.Min(float64(cw)/float64(iw), float64(ch)/float64(ihh))
dw = int(float64(iw) * s)
dh = int(float64(ihh) * s)
}
iup.DrawImage(ih, name, (cw-dw)/2, (ch-dh)/2, dw, dh)
return iup.DEFAULT
}
// previewMessage receives a rendered cover from previewRender and triggers a canvas redraw.
func previewMessage(ih iup.Ihandle, s string, i int, p any) int {
if i != previewPage {
return iup.DEFAULT
}
img := p.(cbconvert.Image)
iup.GetHandle("Loading").SetAttributes("VISIBLE=NO, STOP=YES")
if img.Image != nil && len(s) == 0 {
iup.Destroy(iup.GetHandle("cover"))
iup.ImageFromImage(img.Image).SetHandle("cover")
hasCover = true
iup.GetHandle("PreviewInfo").SetAttribute("TITLE", fmt.Sprintf("%s (%dx%d)", img.SizeHuman, img.Width, img.Height))
} else {
iup.Destroy(iup.GetHandle("cover"))
hasCover = false
iup.GetHandle("PreviewInfo").SetAttribute("TITLE", "")
sp := strings.Split(s, ": ")
if len(sp) > 1 {
iup.MessageError(ih, fmt.Sprintf("%s\n\n%s", sp[0], strings.Join(sp[1:], ": ")))
}
}
iup.Update(ih)
return iup.DEFAULT
}
// pageBox is the page-navigation spin shown in the status bar; hidden until a comic is selected.
func pageBox() iup.Ihandle {
return iup.Hbox(
iup.Space().SetAttribute("SIZE", "5"),
iup.Label("Page:"),
iup.Space().SetAttribute("SIZE", "3"),
iup.Text().SetAttributes(`SPIN=YES, SPINMIN=1, SPINMAX=1, VALUE=1, VISIBLECOLUMNS=3, MASK="/d*"`).SetHandle("Page").
SetAttribute("TIP", "Preview a different page of the selected comic").
SetCallback("SPIN_CB", iup.SpinFunc(func(ih iup.Ihandle, pos int) int {
return onPageChanged()
})).
SetCallback("VALUECHANGED_CB", iup.ValueChangedFunc(func(ih iup.Ihandle) int {
return onPageChanged()
})).
SetCallback("POSTMESSAGE_CB", iup.PostMessageFunc(func(ih iup.Ihandle, s string, i int, p any) int {
if s != previewPath {
return iup.DEFAULT
}
ih.SetAttribute("SPINMAX", strconv.Itoa(i))
iup.GetHandle("PageCount").SetAttribute("TITLE", fmt.Sprintf("/ %d", i))
if previewPage > i-1 {
previewPage = i - 1
}
if previewPage < 0 {
previewPage = 0
}
ih.SetAttribute("VALUE", strconv.Itoa(previewPage+1))
previewRender()
return iup.DEFAULT
})),
iup.Space().SetAttribute("SIZE", "3"),
iup.Label("").SetHandle("PageCount"),
).SetAttributes("ALIGNMENT=ACENTER, VISIBLE=NO").SetHandle("PageBox")
}
func tabInput() iup.Ihandle {
return iup.Hbox(
iup.Vbox(
@@ -586,6 +640,7 @@ func buttons() iup.Ihandle {
func status() iup.Ihandle {
return iup.Hbox(
loading(),
pageBox(),
iup.Fill(),
iup.Label("File 1 of 1").SetHandle("LabelStatus1").SetAttributes("VISIBLE=NO"),
iup.Space().SetAttribute("SIZE", "5"),
+306 -55
View File
@@ -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 <command> [<flags>] [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
}
}
+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)
}
}