Files
cbconvert/cbconvert.go
Milan Nikolic a5a334eae9 initial commit
2015-10-30 02:33:45 +01:00

821 lines
19 KiB
Go

// Author: Milan Nikolic <gen2brain@gmail.com>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
package main
import (
"archive/zip"
"bytes"
"crypto/md5"
"errors"
"fmt"
"image"
"image/color"
_ "image/gif"
"image/jpeg"
"image/png"
"io"
"io/ioutil"
"mime"
"os"
"os/signal"
"path/filepath"
"runtime"
"sort"
"strconv"
"strings"
"sync"
"syscall"
"github.com/MStoykov/go-libarchive"
"github.com/cheggaaa/go-poppler"
"github.com/cheggaaa/pb"
"github.com/gographics/imagick/imagick"
_ "github.com/hotei/bmp"
"github.com/nfnt/resize"
"github.com/skarademir/naturalsort"
"gopkg.in/alecthomas/kingpin.v2"
)
var (
opts options
workdir string
nfiles int
current int
wg sync.WaitGroup
)
// Command line options
type options struct {
ToPNG bool // encode images to PNG instead of JPEG
ToBMP bool // encode images to 4-Bit BMP instead of JPEG
Quality int // JPEG image quality
NoRGB bool // do not convert images with RGB colorspace
Width uint // image width
Height uint // image height
Interpolation int // 0=NearestNeighbor, 1=Bilinear, 2=Bicubic, 3=MitchellNetravali, 4=Lanczos2, 5=Lanczos3
Suffix string // add suffix to file basename
Cover bool // extract cover
Thumbnail bool // extract cover thumbnail (freedesktop spec.)
Outdir string // output directory
Recursive bool // process subdirectories recursively
Size int64 // process only files larger then size (in MB)
Quiet bool // hide console output
}
// Command line arguments
var arguments []string
// Limits go routines to number of CPUs + 1
var throttle = make(chan int, runtime.NumCPU()+1)
// Converts image
func convertImage(img image.Image, index int, pathName string) {
defer wg.Done()
var ext string = "jpg"
if opts.ToPNG {
ext = "png"
} else if opts.ToBMP {
ext = "bmp"
}
var filename string
if pathName != "" {
filename = filepath.Join(workdir, fmt.Sprintf("%s.%s", getBasename(pathName), ext))
} else {
filename = filepath.Join(workdir, fmt.Sprintf("%03d.%s", index, ext))
}
var i image.Image
if opts.Width > 0 || opts.Height > 0 {
i = resize.Resize(opts.Width, opts.Height, img,
resize.InterpolationFunction(opts.Interpolation))
} else {
i = img
}
if opts.ToPNG {
// convert image to PNG
f, err := os.Create(filename)
if err != nil {
fmt.Fprintf(os.Stderr, "Error Create: %v\n", err.Error())
}
defer f.Close()
png.Encode(f, i)
} else if opts.ToBMP {
// convert image to 4-Bit - 16 colors BMP
imagick.Initialize()
mw := imagick.NewMagickWand()
defer mw.Destroy()
b := new(bytes.Buffer)
jpeg.Encode(b, i, &jpeg.Options{jpeg.DefaultQuality})
err := mw.ReadImageBlob(b.Bytes())
if err != nil {
fmt.Fprintf(os.Stderr, "Error ReadImageBlob: %v\n", err.Error())
}
w := imagick.NewPixelWand()
w.SetColor("black")
defer w.Destroy()
mw.SetImageBackgroundColor(w)
mw.SetImageAlphaChannel(imagick.ALPHA_CHANNEL_REMOVE)
mw.SetImageAlphaChannel(imagick.ALPHA_CHANNEL_DEACTIVATE)
mw.SetImageMatte(false)
mw.SetImageCompression(imagick.COMPRESSION_NO)
mw.QuantizeImage(16, imagick.COLORSPACE_SRGB, 8, true, true)
mw.WriteImage(fmt.Sprintf("BMP3:%s", filename))
} else {
// convert image to JPEG (default)
f, err := os.Create(filename)
if err != nil {
fmt.Fprintf(os.Stderr, "Error Create: %v\n", err.Error())
}
defer f.Close()
jpeg.Encode(f, i, &jpeg.Options{opts.Quality})
}
<-throttle
}
// Converts pdf file to cbz
func convertPDF(file string) {
workdir, _ = ioutil.TempDir(os.TempDir(), "cbc")
doc, err := poppler.Open(file)
if err != nil {
fmt.Fprintf(os.Stderr, "Skipping %s, error: %v", file, err.Error())
return
}
npages := doc.GetNPages()
var bar *pb.ProgressBar
if !opts.Quiet {
bar = pb.New(npages)
bar.ShowTimeLeft = false
bar.Prefix(fmt.Sprintf("Converting %d of %d: ", current, nfiles))
bar.Start()
}
for n := 0; n < npages; n++ {
if !opts.Quiet {
bar.Increment()
}
page := doc.GetPage(n)
images := page.Images()
if len(images) == 1 {
throttle <- 1
wg.Add(1)
surface := images[0].GetSurface()
go convertImage(surface.GetImage(), page.Index(), "")
} else {
// FIXME merge images?
}
}
wg.Wait()
}
// Converts archive to cbz
func convertArchive(file string) {
workdir, _ = ioutil.TempDir(os.TempDir(), "cbc")
f, err := os.Open(file)
if err != nil {
fmt.Fprintf(os.Stderr, "Error Open: %v\n", err.Error())
return
}
defer f.Close()
reader, err := archive.NewReader(f)
if err != nil {
fmt.Fprintf(os.Stderr, "Error NewReader: %v\n", err.Error())
}
defer reader.Free()
defer reader.Close()
var bar *pb.ProgressBar
if !opts.Quiet {
s, _ := f.Stat()
bar = pb.New(int(s.Size()))
bar.SetUnits(pb.U_BYTES)
bar.ShowTimeLeft = false
bar.Prefix(fmt.Sprintf("Converting %d of %d: ", current, nfiles))
bar.Start()
}
for {
entry, err := reader.Next()
if err != nil {
if err == archive.ErrArchiveEOF {
break
} else {
fmt.Fprintf(os.Stderr, "Error Next: %v\n", err.Error())
continue
}
}
stat := entry.Stat()
if stat.Mode()&os.ModeType != 0 || stat.IsDir() {
continue
}
if !opts.Quiet {
size := reader.Size()
bar.Set(size)
}
pathname := entry.PathName()
if isImage(pathname) {
buf := new(bytes.Buffer)
_, err := buf.ReadFrom(reader)
if err != nil {
fmt.Fprintf(os.Stderr, "Error ReadFrom: %v\n", err.Error())
continue
}
img, err := decodeImage(bytes.NewReader(buf.Bytes()), pathname)
if err != nil {
fmt.Fprintf(os.Stderr, "Error Decode: %v\n", err.Error())
continue
}
if opts.NoRGB && !isGrayScale(img) {
copyFile(bytes.NewReader(buf.Bytes()), filepath.Join(workdir, filepath.Base(pathname)))
continue
}
if img != nil {
throttle <- 1
wg.Add(1)
go convertImage(img, 0, pathname)
}
} else {
copyFile(reader, filepath.Join(workdir, filepath.Base(pathname)))
}
}
wg.Wait()
}
// Converts directory to cbz
func convertDirectory(path string) {
workdir, _ = ioutil.TempDir(os.TempDir(), "cbc")
images := getImages(path)
var bar *pb.ProgressBar
if !opts.Quiet {
bar = pb.New(nfiles)
bar.ShowTimeLeft = false
bar.Prefix(fmt.Sprintf("Converting %d of %d: ", current, nfiles))
bar.Start()
}
for index, img := range images {
if opts.Quiet {
bar.Increment()
}
f, err := os.Open(img)
if err != nil {
fmt.Fprintf(os.Stderr, "Error Open: %v\n", err.Error())
continue
}
i, err := decodeImage(f, img)
if err != nil {
fmt.Fprintf(os.Stderr, "Error Decode: %v\n", err.Error())
continue
}
if opts.NoRGB && !isGrayScale(i) {
copyFile(f, filepath.Join(workdir, filepath.Base(img)))
continue
}
f.Close()
if i != nil {
throttle <- 1
wg.Add(1)
go convertImage(i, index, img)
}
}
wg.Wait()
}
// Saves workdir to cbz archive
func saveArchive(file string) {
defer os.RemoveAll(workdir)
zipname := filepath.Join(opts.Outdir, fmt.Sprintf("%s%s.cbz", getBasename(file), opts.Suffix))
zipfile, err := os.Create(zipname)
if err != nil {
fmt.Fprintf(os.Stderr, "Error Create: %v\n", err.Error())
return
}
defer zipfile.Close()
z := zip.NewWriter(zipfile)
files, _ := ioutil.ReadDir(workdir)
var bar *pb.ProgressBar
if !opts.Quiet {
bar = pb.New(len(files))
bar.ShowTimeLeft = false
bar.Prefix(fmt.Sprintf("Compressing %d of %d: ", current, nfiles))
bar.Start()
}
for _, file := range files {
if !opts.Quiet {
bar.Increment()
}
r, err := ioutil.ReadFile(filepath.Join(workdir, file.Name()))
if err != nil {
fmt.Fprintf(os.Stderr, "Error ReadFile: %v\n", err.Error())
continue
}
w, err := z.Create(file.Name())
if err != nil {
fmt.Fprintf(os.Stderr, "Error Create: %v\n", err.Error())
continue
}
w.Write(r)
}
z.Close()
}
// Unpacks archive to directory
func unpackArchive(file string, dir string) {
f, err := os.Open(file)
if err != nil {
fmt.Fprintf(os.Stderr, "Error Open: %v\n", err.Error())
return
}
defer f.Close()
reader, err := archive.NewReader(f)
if err != nil {
fmt.Fprintf(os.Stderr, "Error NewReader: %v\n", err.Error())
return
}
defer reader.Free()
defer reader.Close()
for {
entry, err := reader.Next()
if err != nil {
if err == archive.ErrArchiveEOF {
break
} else {
continue
}
}
if entry.Stat().Mode()&os.ModeType == 0 {
err = copyFile(reader, filepath.Join(dir, entry.PathName()))
}
if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err.Error())
}
}
}
// Extracts cover from archive
func coverArchive(file string) (image.Image, error) {
tmpdir, _ := ioutil.TempDir(os.TempDir(), "cbc")
defer os.RemoveAll(tmpdir)
unpackArchive(file, tmpdir)
images := getImages(tmpdir)
if len(images) == 0 {
return nil, errors.New("No images")
}
cover := getCover(images)
p, err := os.Open(cover)
if err != nil {
return nil, err
}
defer p.Close()
img, err := decodeImage(p, cover)
if err != nil {
return nil, err
}
return img, nil
}
// Extracts cover from pdf
func coverPDF(file string) (image.Image, error) {
doc, err := poppler.Open(file)
if err != nil {
return nil, err
}
page := doc.GetPage(0)
images := page.Images()
if len(images) == 1 {
surface := images[0].GetSurface()
img := surface.GetImage()
if img == nil {
return nil, errors.New("Image is nil")
}
return img, nil
}
return nil, nil
}
// Extracts cover from directory
func coverDirectory(dir string) (image.Image, error) {
images := getImages(dir)
cover := getCover(images)
p, err := os.Open(cover)
if err != nil {
return nil, err
}
defer p.Close()
img, err := decodeImage(p, cover)
if err != nil {
fmt.Fprintf(os.Stderr, "Error Decode: %v\n", err.Error())
return nil, err
}
if img == nil {
return nil, errors.New("Image is nil")
}
return img, nil
}
// Returns list of found comic files
func getFiles() []string {
var files []string
walkFiles := func(fp string, f os.FileInfo, err error) error {
if !f.IsDir() {
if isComic(fp) {
if isSize(f.Size()) {
files = append(files, fp)
}
}
}
return nil
}
for _, arg := range arguments {
path, _ := filepath.Abs(arg)
stat, err := os.Stat(path)
if err != nil {
fmt.Fprintf(os.Stderr, "Error Stat: %v\n", err.Error())
continue
}
if !stat.IsDir() {
if isComic(path) {
if isSize(stat.Size()) {
files = append(files, path)
}
}
} else {
if opts.Recursive {
filepath.Walk(path, walkFiles)
} else {
fs, _ := ioutil.ReadDir(path)
for _, f := range fs {
if isComic(f.Name()) {
if isSize(f.Size()) {
files = append(files, f.Name())
}
}
}
}
if len(files) == 0 {
// append plain directory with images
files = append(files, path)
}
}
}
return files
}
// Returns sorted list of found image files for given directory
func getImages(path string) []string {
var images []string
walkFiles := func(fp string, f os.FileInfo, err error) error {
if !f.IsDir() && f.Mode()&os.ModeType == 0 {
if f.Size() > 0 && isImage(fp) {
images = append(images, fp)
}
}
return nil
}
f, _ := filepath.Abs(path)
stat, err := os.Stat(f)
if err != nil {
fmt.Fprintf(os.Stderr, "Error Stat: %v\n", err.Error())
return images
}
if !stat.IsDir() && stat.Mode()&os.ModeType == 0 {
if isImage(f) {
images = append(images, f)
}
} else {
filepath.Walk(f, walkFiles)
}
return images
}
// Returns the filename that is the most likely to be the cover
func getCover(images []string) string {
for _, i := range images {
if strings.HasPrefix(i, "cover") || strings.HasPrefix(i, "front") {
return i
}
}
sort.Sort(naturalsort.NaturalSort(images))
return images[0]
}
// Checks if file is comic
func isComic(f string) bool {
var types = []string{".rar", ".zip", ".7z", ".gz", ".bz2",
".cbr", ".cbz", ".cb7", ".cbt", ".pdf"}
for _, t := range types {
if strings.ToLower(filepath.Ext(f)) == t {
return true
}
}
return false
}
// Checks if file is image
func isImage(f string) bool {
var types = []string{".jpg", ".jpeg", ".jpe",
".png", ".gif", ".bmp"}
for _, t := range types {
if strings.ToLower(filepath.Ext(f)) == t {
return true
}
}
return false
}
// Checks size of file
func isSize(size int64) bool {
if opts.Size > 0 {
if size < opts.Size*(1024*1024) {
return false
}
}
return true
}
// Checks if image is grayscale
func isGrayScale(img image.Image) bool {
model := img.ColorModel()
if model == color.GrayModel || model == color.Gray16Model {
return true
}
return false
}
// Decodes image from reader
func decodeImage(reader io.Reader, filename string) (i image.Image, err error) {
defer func() {
if r := recover(); r != nil {
fmt.Fprintf(os.Stderr, "Recovered in decodeImage %s: %v\n", filename, r)
}
}()
i, _, err = image.Decode(reader)
return i, err
}
// Copies reader to file
func copyFile(reader io.Reader, filename string) error {
os.MkdirAll(filepath.Dir(filename), 0755)
file, err := os.Create(filename)
if err != nil {
return err
}
defer file.Close()
_, err = io.Copy(file, reader)
if err != nil {
return err
}
return nil
}
// Returns basename without extension
func getBasename(file string) string {
basename := strings.TrimSuffix(filepath.Base(file), filepath.Ext(file))
basename = strings.TrimSuffix(basename, ".tar")
return basename
}
// Extracts cover
func extractCover(file string, info os.FileInfo) {
var err error
var cover image.Image
if info.IsDir() {
cover, err = coverDirectory(file)
} else if strings.ToLower(filepath.Ext(file)) == ".pdf" {
cover, err = coverPDF(file)
} else {
cover, err = coverArchive(file)
}
if err != nil {
fmt.Fprintf(os.Stderr, "Error Cover: %v\n", err.Error())
return
}
if opts.Width > 0 || opts.Height > 0 {
cover = resize.Resize(opts.Width, opts.Height, cover,
resize.InterpolationFunction(opts.Interpolation))
}
filename := filepath.Join(opts.Outdir, fmt.Sprintf("%s.jpg", getBasename(file)))
f, err := os.Create(filename)
if err != nil {
fmt.Fprintf(os.Stderr, "Error Create: %v\n", err.Error())
return
}
defer f.Close()
jpeg.Encode(f, cover, &jpeg.Options{opts.Quality})
}
// Extracts thumbnail
func extractThumbnail(file string, info os.FileInfo) {
var err error
var cover image.Image
if info.IsDir() {
cover, err = coverDirectory(file)
} else if strings.ToLower(filepath.Ext(file)) == ".pdf" {
cover, err = coverPDF(file)
} else {
cover, err = coverArchive(file)
}
if err != nil {
fmt.Fprintf(os.Stderr, "Error Thumbnail: %v\n", err.Error())
return
}
if opts.Width > 0 || opts.Height > 0 {
cover = resize.Resize(opts.Width, opts.Height, cover,
resize.InterpolationFunction(opts.Interpolation))
} else {
cover = resize.Resize(256, 0, cover,
resize.InterpolationFunction(opts.Interpolation))
}
imagick.Initialize()
mw := imagick.NewMagickWand()
defer mw.Destroy()
b := new(bytes.Buffer)
png.Encode(b, cover)
err = mw.ReadImageBlob(b.Bytes())
if err != nil {
fmt.Fprintf(os.Stderr, "Error ReadImageBlob: %v\n", err.Error())
}
fileuri := "file://" + file
filename := filepath.Join(opts.Outdir, fmt.Sprintf("%x.png", md5.Sum([]byte(fileuri))))
mw.SetImageFormat("png")
mw.SetImageProperty("Software", "cbconvert")
mw.SetImageProperty("Description", "Thumbnail of "+fileuri)
mw.SetImageProperty("Thumb::URI", fileuri)
mw.SetImageProperty("Thumb::MTime", strconv.FormatInt(info.ModTime().Unix(), 10))
mw.SetImageProperty("Thumb::Size", strconv.FormatInt(info.Size(), 10))
mw.SetImageProperty("Thumb::Mimetype", mime.TypeByExtension(filepath.Ext(file)))
mw.WriteImage(filename)
}
// Converts comic book
func convertComic(file string, info os.FileInfo) {
if info.IsDir() {
convertDirectory(file)
saveArchive(file)
} else if strings.ToLower(filepath.Ext(file)) == ".pdf" {
convertPDF(file)
saveArchive(file)
} else {
convertArchive(file)
saveArchive(file)
}
}
// Parses command line flags
func parseFlags() {
opts = options{}
kingpin.Version("CBconvert 0.1.0")
kingpin.CommandLine.Help = "Comic Book convert tool."
kingpin.Flag("png", "encode images to PNG instead of JPEG").Short('p').BoolVar(&opts.ToPNG)
kingpin.Flag("bmp", "encode images to 4-Bit BMP instead of JPEG").Short('b').BoolVar(&opts.ToBMP)
kingpin.Flag("width", "image width").Default(strconv.Itoa(0)).Short('w').UintVar(&opts.Width)
kingpin.Flag("height", "image height").Default(strconv.Itoa(0)).Short('h').UintVar(&opts.Height)
kingpin.Flag("quality", "JPEG image quality").Short('q').Default(strconv.Itoa(jpeg.DefaultQuality)).IntVar(&opts.Quality)
kingpin.Flag("norgb", "do not convert images with RGB colorspace").Short('n').BoolVar(&opts.NoRGB)
kingpin.Flag("interpolation", "0=NearestNeighbor, 1=Bilinear, 2=Bicubic, 3=MitchellNetravali, 4=Lanczos2, 5=Lanczos3").Short('i').
Default(strconv.Itoa(int(resize.Bilinear))).IntVar(&opts.Interpolation)
kingpin.Flag("suffix", "add suffix to file basename").Short('s').StringVar(&opts.Suffix)
kingpin.Flag("cover", "extract cover").Short('c').BoolVar(&opts.Cover)
kingpin.Flag("thumbnail", "extract cover thumbnail (freedesktop spec.)").Short('t').BoolVar(&opts.Thumbnail)
kingpin.Flag("outdir", "output directory").Default(".").Short('o').StringVar(&opts.Outdir)
kingpin.Flag("size", "process only files larger then size (in MB)").Short('m').Default(strconv.Itoa(0)).Int64Var(&opts.Size)
kingpin.Flag("recursive", "process subdirectories recursively").Short('r').BoolVar(&opts.Recursive)
kingpin.Flag("quiet", "hide console output").Short('v').BoolVar(&opts.Quiet)
kingpin.Arg("args", "filename or directory").Required().ExistingFilesOrDirsVar(&arguments)
kingpin.Parse()
}
func main() {
parseFlags()
c := make(chan os.Signal, 3)
signal.Notify(c, os.Interrupt, syscall.SIGHUP, syscall.SIGTERM)
go func() {
for _ = range c {
os.RemoveAll(workdir)
os.Exit(1)
}
}()
if _, err := os.Stat(opts.Outdir); err != nil {
os.MkdirAll(opts.Outdir, 0777)
}
files := getFiles()
nfiles = len(files)
for n, file := range files {
current = n + 1
stat, err := os.Stat(file)
if err != nil {
fmt.Fprintf(os.Stderr, "Error Stat: %v\n", err.Error())
continue
}
if opts.Cover {
extractCover(file, stat)
continue
} else if opts.Thumbnail {
extractThumbnail(file, stat)
continue
}
convertComic(file, stat)
}
}