mirror of
https://github.com/gen2brain/cbconvert
synced 2026-07-24 12:25:38 +02:00
Compare commits
3
Commits
0439a2edde
...
4e2f78026d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4e2f78026d | ||
|
|
e1134cd902 | ||
|
|
f289c9cd06 |
+23
-10
@@ -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
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user