diff --git a/cbconvert.go b/cbconvert.go index d1ffa06..6994058 100644 --- a/cbconvert.go +++ b/cbconvert.go @@ -6,6 +6,8 @@ import ( "bytes" "context" "crypto/md5" + "crypto/rand" + "encoding/hex" "errors" "fmt" "io" @@ -25,7 +27,7 @@ import ( "image/png" "github.com/chai2010/webp" - _ "github.com/hotei/bmp" // allow bmp decoding + _ "github.com/hotei/bmp" // allow 4-bit bmp decoding "github.com/strukturag/libheif/go/heif" "golang.org/x/image/tiff" @@ -100,6 +102,8 @@ type Options struct { Thumbnail bool // CBZ metadata Meta bool + // Version + Version bool // ZIP comment Comment bool // ZIP comment body @@ -166,7 +170,15 @@ type Convertor struct { type File struct { Name string Path string - Size int64 + Stat os.FileInfo + SizeHuman string +} + +// Image type. +type Image struct { + Image image.Image + Width int + Height int SizeHuman string } @@ -290,9 +302,10 @@ func (c *Convertor) convertArchive(fileName string) error { var img image.Image img, err = c.imageDecode(bytes.NewReader(data), pathName) if err != nil { + e := err img, err = c.imDecode(bytes.NewReader(data), pathName) if err != nil { - return fmt.Errorf("convertArchive: %w", err) + return fmt.Errorf("convertArchive: %w: %w", e, err) } } @@ -391,9 +404,15 @@ func (c *Convertor) convertDirectory(dirPath string) error { var i image.Image i, err = c.imageDecode(file, img) if err != nil { + e := err + _, err = file.Seek(0, io.SeekStart) + if err != nil { + return fmt.Errorf("convertDirectory: %w: %w", e, err) + } + i, err = c.imDecode(file, img) if err != nil { - return fmt.Errorf("convertDirectory: %w", err) + return fmt.Errorf("convertDirectory: %w: %w", e, err) } } @@ -517,6 +536,10 @@ func (c *Convertor) imageTransform(img image.Image) image.Image { i = imaging.AdjustContrast(i, float64(c.Opts.Contrast)) } + if c.Opts.Grayscale { + i = imageToGray(i) + } + return i } @@ -552,9 +575,10 @@ func (c *Convertor) imageLevel(img image.Image) (image.Image, error) { var i image.Image i, err = c.imageDecode(bytes.NewReader(blob), "levels") if err != nil { + e := err i, err = c.imDecode(bytes.NewReader(blob), "levels") if err != nil { - return nil, fmt.Errorf("imageLevel: %w", err) + return nil, fmt.Errorf("imageLevel: %w: %w", e, err) } } @@ -622,10 +646,6 @@ func (c *Convertor) imageEncode(img image.Image, fileName string) error { } defer file.Close() - if c.Opts.Grayscale { - img = imageToGray(img) - } - switch filepath.Ext(fileName) { case ".png": err = png.Encode(file, img) @@ -667,12 +687,6 @@ func (c *Convertor) imEncode(i image.Image, fileName string) error { return fmt.Errorf("imEncode: %w", err) } - if c.Opts.Grayscale { - if err := mw.TransformImageColorspace(imagick.COLORSPACE_GRAY); err != nil { - return fmt.Errorf("imEncode: %w", err) - } - } - switch filepath.Ext(fileName) { case ".png": if err := mw.SetImageFormat("PNG"); err != nil { @@ -1187,9 +1201,10 @@ func (c *Convertor) coverArchive(fileName string) (image.Image, error) { var img image.Image img, err = c.imageDecode(bytes.NewReader(data), cover) if err != nil { + e := err img, err = c.imDecode(bytes.NewReader(data), cover) if err != nil { - return nil, fmt.Errorf("coverArchive: %w", err) + return nil, fmt.Errorf("coverArchive: %w: %w", e, err) } } @@ -1232,9 +1247,15 @@ func (c *Convertor) coverDirectory(dir string) (image.Image, error) { var img image.Image img, err = c.imageDecode(file, cover) if err != nil { + e := err + _, err = file.Seek(0, io.SeekStart) + if err != nil { + return nil, fmt.Errorf("coverDirectory: %w: %w", e, err) + } + img, err = c.imDecode(file, cover) if err != nil { - return nil, fmt.Errorf("coverDirectory: %w", err) + return nil, fmt.Errorf("coverDirectory: %w: %w", e, err) } } @@ -1448,6 +1469,13 @@ func (c *Convertor) baseNoExt(filename string) string { return strings.TrimSuffix(filepath.Base(filename), filepath.Ext(filename)) } +// tempName generates a temporary name. +func (c *Convertor) tempName(prefix, suffix string) string { + randBytes := make([]byte, 16) + _, _ = rand.Read(randBytes) + return filepath.Join(os.TempDir(), prefix+hex.EncodeToString(randBytes)+suffix) +} + // copyFile copies reader to file. func (c *Convertor) copyFile(reader io.Reader, filename string) error { err := os.MkdirAll(filepath.Dir(filename), 0755) @@ -1487,8 +1515,8 @@ func (c *Convertor) Files(args []string) ([]File, error) { var file File file.Name = filepath.Base(fp) file.Path = fp - file.Size = f.Size() - file.SizeHuman = humanize.IBytes(uint64(file.Size)) + file.Stat = f + file.SizeHuman = humanize.IBytes(uint64(f.Size())) return file } @@ -1630,10 +1658,10 @@ func (c *Convertor) Cover(fileName string, fileInfo os.FileInfo) error { } // Thumbnail extracts thumbnail. -func (c *Convertor) Thumbnail(fileName string, info os.FileInfo) error { +func (c *Convertor) Thumbnail(fileName string, fileInfo os.FileInfo) error { c.CurrFile++ - cover, err := c.coverImage(fileName, info) + cover, err := c.coverImage(fileName, fileInfo) if err != nil { return fmt.Errorf("%s: %w", fileName, err) } @@ -1691,10 +1719,10 @@ func (c *Convertor) Thumbnail(fileName string, info os.FileInfo) error { if err := mw.SetImageProperty("Thumb::URI", fURI); err != nil { return fmt.Errorf("%s: %w", fileName, err) } - if err := mw.SetImageProperty("Thumb::MTime", strconv.FormatInt(info.ModTime().Unix(), 10)); err != nil { + if err := mw.SetImageProperty("Thumb::MTime", strconv.FormatInt(fileInfo.ModTime().Unix(), 10)); err != nil { return fmt.Errorf("%s: %w", fileName, err) } - if err := mw.SetImageProperty("Thumb::Size", strconv.FormatInt(info.Size(), 10)); err != nil { + if err := mw.SetImageProperty("Thumb::Size", strconv.FormatInt(fileInfo.Size(), 10)); err != nil { return fmt.Errorf("%s: %w", fileName, err) } @@ -1753,12 +1781,88 @@ func (c *Convertor) Meta(fileName string) (any, error) { return "", nil } +// Preview returns image preview. +func (c *Convertor) 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) + } + + i = c.imageTransform(i) + + if c.Opts.LevelsInMin != 0 || c.Opts.LevelsInMax != 255 || c.Opts.LevelsGamma != 1.0 || + c.Opts.LevelsOutMin != 0 || c.Opts.LevelsOutMax != 255 { + i, err = c.imageLevel(i) + if err != nil { + return img, fmt.Errorf("%s: %w", fileName, err) + } + } + + tmpName := c.tempName("cbc", "."+c.Opts.Format) + + switch c.Opts.Format { + case "jpeg", "png", "tiff", "webp", "avif": + if err := c.imageEncode(i, tmpName); err != nil { + return img, fmt.Errorf("%s: %w", fileName, err) + } + case "bmp": + if err := c.imEncode(i, tmpName); err != nil { + return img, fmt.Errorf("%s: %w", fileName, err) + } + } + + stat, err := os.Stat(tmpName) + if err != nil { + return img, fmt.Errorf("%s: %w", fileName, err) + } + + img.Width = i.Bounds().Dx() + img.Height = i.Bounds().Dy() + img.SizeHuman = humanize.IBytes(uint64(stat.Size())) + + f, err := os.Open(tmpName) + if err != nil { + return img, fmt.Errorf("%s: %w", fileName, err) + } + + defer os.Remove(tmpName) + + dec, err := c.imageDecode(f, tmpName) + if err != nil { + e := err + _, err = f.Seek(0, io.SeekStart) + if err != nil { + return img, fmt.Errorf("%s: %w: %w", tmpName, e, err) + } + + dec, err = c.imDecode(f, tmpName) + if err != nil { + return img, fmt.Errorf("%s: %w: %w", tmpName, e, err) + } + } + + err = f.Close() + if err != nil { + return img, fmt.Errorf("%s: %w", fileName, err) + } + + if width != 0 && height != 0 { + dec = imaging.Fit(dec, width, height, filters[c.Opts.Filter]) + } + + img.Image = dec + + return img, nil +} + // Convert converts comic book. -func (c *Convertor) Convert(fileName string, info os.FileInfo) error { +func (c *Convertor) Convert(fileName string, fileInfo os.FileInfo) error { c.CurrFile++ switch { - case info.IsDir(): + case fileInfo.IsDir(): if err := c.convertDirectory(fileName); err != nil { return fmt.Errorf("%s: %w", fileName, err) } diff --git a/cmd/cbconvert/main.go b/cmd/cbconvert/main.go index 8722b57..9836c01 100644 --- a/cmd/cbconvert/main.go +++ b/cmd/cbconvert/main.go @@ -7,6 +7,7 @@ import ( "os" "os/signal" "path/filepath" + "runtime/debug" "syscall" "github.com/gen2brain/cbconvert" @@ -14,8 +15,43 @@ import ( flag "github.com/spf13/pflag" ) +var appVersion string + +func init() { + if appVersion != "" { + return + } + + info, ok := debug.ReadBuildInfo() + if !ok { + return + } + + if info.Main.Version != "" { + appVersion = info.Main.Version + } + + for _, kv := range info.Settings { + if kv.Value == "" { + continue + } + if kv.Key == "vcs.revision" { + appVersion = kv.Value + if len(appVersion) > 10 { + appVersion = kv.Value[:10] + } + } + } +} + func main() { opts, args := parseFlags() + + if opts.Version { + fmt.Println(filepath.Base(os.Args[0]), appVersion) + os.Exit(0) + } + conv := cbconvert.New(opts) c := make(chan os.Signal, 2) @@ -83,12 +119,6 @@ func main() { } for _, file := range files { - stat, err := os.Stat(file.Path) - if err != nil { - fmt.Println(err) - os.Exit(1) - } - switch { case opts.Meta: ret, err := conv.Meta(file.Path) @@ -105,14 +135,14 @@ func main() { continue case opts.Cover: - if err := conv.Cover(file.Path, stat); err != nil { + if err := conv.Cover(file.Path, file.Stat); err != nil { fmt.Println(err) os.Exit(1) } continue case opts.Thumbnail: - if err = conv.Thumbnail(file.Path, stat); err != nil { + if err = conv.Thumbnail(file.Path, file.Stat); err != nil { fmt.Println(err) os.Exit(1) } @@ -120,7 +150,7 @@ func main() { continue } - if err := conv.Convert(file.Path, stat); err != nil { + if err := conv.Convert(file.Path, file.Stat); err != nil { fmt.Println(err) os.Exit(1) } @@ -197,34 +227,32 @@ func parseFlags() (cbconvert.Options, []string) { meta.StringVar(&opts.FileAdd, "file-add", "", "Add file to archive") meta.StringVar(&opts.FileRemove, "file-remove", "", "Remove file from archive (glob pattern, i.e. *.xml)") - convert.Usage = func() { + flag.NewFlagSet("version", flag.ExitOnError) + + flag.Usage = func() { _, _ = fmt.Fprintf(os.Stderr, "Usage: %s [] [file1 dir1 ... fileOrDirN]\n\n", filepath.Base(os.Args[0])) _, _ = fmt.Fprintf(os.Stderr, "\nCommands:\n") _, _ = fmt.Fprintf(os.Stderr, "\n convert\n \tConvert archive or document\n\n") convert.VisitAll(func(f *flag.Flag) { - _, _ = fmt.Fprintf(os.Stderr, " --%s", f.Name) - _, _ = fmt.Fprintf(os.Stderr, "\n \t") + _, _ = 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 cover\n \tExtract cover\n\n") cover.VisitAll(func(f *flag.Flag) { - _, _ = fmt.Fprintf(os.Stderr, " --%s", f.Name) - _, _ = fmt.Fprintf(os.Stderr, "\n \t") + _, _ = 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") thumbnail.VisitAll(func(f *flag.Flag) { - _, _ = fmt.Fprintf(os.Stderr, " --%s", f.Name) - _, _ = fmt.Fprintf(os.Stderr, "\n \t") + _, _ = 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 meta\n \tCBZ metadata\n\n") meta.VisitAll(func(f *flag.Flag) { - _, _ = fmt.Fprintf(os.Stderr, " --%s", f.Name) - _, _ = fmt.Fprintf(os.Stderr, "\n \t") + _, _ = 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") + _, _ = fmt.Fprintf(os.Stderr, "\n version\n \tPrint version\n\n") } if len(os.Args) < 2 { @@ -262,10 +290,12 @@ func parseFlags() (cbconvert.Options, []string) { if !pipe { args = meta.Args() } + case "version": + opts.Version = true } - if len(args) == 0 { - convert.Usage() + if len(args) == 0 && !opts.Version { + flag.Usage() _, _ = fmt.Fprintf(os.Stderr, "no arguments\n") os.Exit(1) }