Files
cbconvert/cbconvert.go
2015-11-05 10:09:44 +01:00

918 lines
21 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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 cbconvert
import (
"archive/zip"
"bytes"
"crypto/md5"
"errors"
"fmt"
"image"
"image/color"
"image/gif"
"image/jpeg"
"image/png"
"io"
"io/ioutil"
"mime"
"os"
"path/filepath"
"runtime"
"sort"
"strconv"
"strings"
"sync"
"github.com/cheggaaa/pb"
"github.com/disintegration/imaging"
"github.com/gen2brain/go-fitz"
"github.com/gen2brain/go-unarr"
"github.com/gographics/imagick/imagick"
_ "github.com/hotei/bmp"
"github.com/skarademir/naturalsort"
"golang.org/x/image/tiff"
_ "golang.org/x/image/webp"
)
// Resample filters
const (
NearestNeighbor int = iota // Fastest resampling filter, no antialiasing
Box // Box filter (averaging pixels)
Linear // Bilinear filter, smooth and reasonably fast
MitchellNetravali // А smooth bicubic filter
CatmullRom // A sharp bicubic filter
Gaussian // Blurring filter that uses gaussian function, useful for noise removal
Lanczos // High-quality resampling filter, it's slower than cubic filters
)
var filters = map[int]imaging.ResampleFilter{
NearestNeighbor: imaging.NearestNeighbor,
Box: imaging.Box,
Linear: imaging.Linear,
MitchellNetravali: imaging.MitchellNetravali,
CatmullRom: imaging.CatmullRom,
Gaussian: imaging.Gaussian,
Lanczos: imaging.Lanczos,
}
var (
bar *pb.ProgressBar
wg sync.WaitGroup
)
// Limits go routines to number of CPUs + 1
var throttle = make(chan int, runtime.NumCPU()+1)
// Options
type Options struct {
ToPNG bool // encode images to PNG instead of JPEG
ToBMP bool // encode images to 4-Bit BMP (16 colors) instead of JPEG
ToGIF bool // encode images to GIF instead of JPEG
ToTIFF bool // encode images to TIFF instead of JPEG
Quality int // JPEG image quality
Width int // image width
Height int // image height
Fit bool // Best fit for required width and height
Filter int // 0=NearestNeighbor, 1=Box, 2=Linear, 3=MitchellNetravali, 4=CatmullRom, 6=Gaussian, 7=Lanczos
RGB bool // convert images that have RGB colorspace
NonImage bool // Leave non image files in archive
Suffix string // add suffix to file basename
Cover bool // extract cover
Thumbnail bool // extract cover thumbnail (freedesktop spec.)
Outdir string // output directory
Grayscale bool // convert images to grayscale (monochromatic)
Rotate int // Rotate images, valid values are 0, 90, 180, 270
Flip string // Flip images, valid values are none, horizontal, vertical
Brightness float64 // Adjust brightness of the images, must be in range (-100, 100)
Contrast float64 // Adjust contrast of the images, must be in range (-100, 100)
Recursive bool // process subdirectories recursively
Size int64 // process only files larger then size (in MB)
Quiet bool // hide console output
}
// Convertor struct
type Convertor struct {
Opts Options // Options struct
Workdir string // Current working directory
Nfiles int // Number of files
Current int // Index of current file
}
// NewConvertor returns new convertor
func NewConvertor(o Options) *Convertor {
c := &Convertor{}
c.Opts = o
return c
}
// Converts image
func (c *Convertor) convertImage(img image.Image, index int, pathName string) {
defer wg.Done()
var ext string = "jpg"
if c.Opts.ToPNG {
ext = "png"
} else if c.Opts.ToBMP {
ext = "bmp"
} else if c.Opts.ToGIF {
ext = "gif"
} else if c.Opts.ToTIFF {
ext = "tiff"
}
var filename string
if pathName != "" {
filename = filepath.Join(c.Workdir, fmt.Sprintf("%s.%s", c.getBasename(pathName), ext))
} else {
filename = filepath.Join(c.Workdir, fmt.Sprintf("%03d.%s", index, ext))
}
if c.Opts.ToPNG {
// convert image to PNG
if c.Opts.Grayscale {
c.encodeImageMagick(img, filename)
} else {
c.encodeImage(img, filename)
}
} else if c.Opts.ToBMP {
// convert image to 4-Bit BMP (16 colors)
c.encodeImageMagick(img, filename)
} else if c.Opts.ToGIF {
// convert image to GIF
c.encodeImageMagick(img, filename)
} else if c.Opts.ToTIFF {
// convert image to TIFF
c.encodeImage(img, filename)
} else {
// convert image to JPEG (default)
if c.Opts.Grayscale {
c.encodeImageMagick(img, filename)
} else {
c.encodeImage(img, filename)
}
}
<-throttle
}
// Transforms image (resize, rotate, flip, brightness, contrast)
func (c *Convertor) transformImage(img image.Image) image.Image {
var i image.Image = img
if c.Opts.Width > 0 || c.Opts.Height > 0 {
if c.Opts.Fit {
i = imaging.Fit(i, c.Opts.Width, c.Opts.Height, filters[c.Opts.Filter])
} else {
i = imaging.Resize(i, c.Opts.Width, c.Opts.Height, filters[c.Opts.Filter])
}
}
if c.Opts.Rotate > 0 {
switch c.Opts.Rotate {
case 90:
i = imaging.Rotate90(i)
case 180:
i = imaging.Rotate180(i)
case 270:
i = imaging.Rotate270(i)
}
}
if c.Opts.Flip != "none" {
switch c.Opts.Flip {
case "horizontal":
i = imaging.FlipH(i)
case "vertical":
i = imaging.FlipV(i)
}
}
if c.Opts.Brightness != 0 {
i = imaging.AdjustBrightness(i, c.Opts.Brightness)
}
if c.Opts.Contrast != 0 {
i = imaging.AdjustContrast(i, c.Opts.Contrast)
}
return i
}
// Converts PDF/EPUB/XPS document to CBZ
func (c *Convertor) convertDocument(file string) {
c.Workdir, _ = ioutil.TempDir(os.TempDir(), "cbc")
doc, err := fitz.NewDocument(file)
if err != nil {
fmt.Fprintf(os.Stderr, "Skipping %s, error: %v", file, err.Error())
return
}
npages := doc.Pages()
if !c.Opts.Quiet {
bar = pb.New(npages)
bar.ShowTimeLeft = false
bar.Prefix(fmt.Sprintf("Converting %d of %d: ", c.Current, c.Nfiles))
bar.Start()
}
for n := 0; n < npages; n++ {
if !c.Opts.Quiet {
bar.Increment()
}
img, err := doc.Image(n)
if err == nil {
img = c.transformImage(img)
}
if img != nil {
throttle <- 1
wg.Add(1)
go c.convertImage(img, n, "")
}
}
wg.Wait()
}
// Converts archive to CBZ
func (c *Convertor) convertArchive(file string) {
c.Workdir, _ = ioutil.TempDir(os.TempDir(), "cbc")
ncontents := len(c.listArchive(file))
archive, err := unarr.NewArchive(file)
if err != nil {
fmt.Fprintf(os.Stderr, "Error NewReader: %v\n", err.Error())
}
defer archive.Close()
if !c.Opts.Quiet {
bar = pb.New(ncontents)
bar.ShowTimeLeft = false
bar.Prefix(fmt.Sprintf("Converting %d of %d: ", c.Current, c.Nfiles))
bar.Start()
}
for {
err := archive.Entry()
if err != nil {
if err == io.EOF {
break
} else {
fmt.Fprintf(os.Stderr, "Error Entry: %v\n", err.Error())
continue
}
}
if !c.Opts.Quiet {
bar.Increment()
}
size := archive.Size()
pathname := archive.Name()
buf := make([]byte, size)
for size > 0 {
n, err := archive.Read(buf)
if err != nil && err != io.EOF {
break
}
size -= n
}
if size > 0 {
fmt.Printf("Error Read\n")
continue
}
if c.isImage(pathname) {
img, err := c.decodeImage(bytes.NewReader(buf), pathname)
if err != nil {
fmt.Fprintf(os.Stderr, "Error Decode: %v\n", err.Error())
continue
}
i := c.transformImage(img)
if !c.Opts.RGB && !c.isGrayScale(i) {
c.encodeImage(i, filepath.Join(c.Workdir, filepath.Base(pathname)))
continue
}
if i != nil {
throttle <- 1
wg.Add(1)
go c.convertImage(i, 0, pathname)
}
} else {
if c.Opts.NonImage {
c.copyFile(bytes.NewReader(buf), filepath.Join(c.Workdir, filepath.Base(pathname)))
}
}
}
wg.Wait()
}
// Converts directory to CBZ
func (c *Convertor) convertDirectory(path string) {
c.Workdir, _ = ioutil.TempDir(os.TempDir(), "cbc")
images := c.getImages(path)
if !c.Opts.Quiet {
bar = pb.New(c.Nfiles)
bar.ShowTimeLeft = false
bar.Prefix(fmt.Sprintf("Converting %d of %d: ", c.Current, c.Nfiles))
bar.Start()
}
for index, img := range images {
if c.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 := c.decodeImage(f, img)
if err != nil {
fmt.Fprintf(os.Stderr, "Error Decode: %v\n", err.Error())
continue
}
i = c.transformImage(i)
if !c.Opts.RGB && !c.isGrayScale(i) {
c.encodeImage(i, filepath.Join(c.Workdir, filepath.Base(img)))
continue
}
f.Close()
if i != nil {
throttle <- 1
wg.Add(1)
go c.convertImage(i, index, img)
}
}
wg.Wait()
}
// Saves workdir to CBZ archive
func (c *Convertor) saveArchive(file string) {
defer os.RemoveAll(c.Workdir)
zipname := filepath.Join(c.Opts.Outdir, fmt.Sprintf("%s%s.cbz", c.getBasename(file), c.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(c.Workdir)
if !c.Opts.Quiet {
bar = pb.New(len(files))
bar.ShowTimeLeft = false
bar.Prefix(fmt.Sprintf("Compressing %d of %d: ", c.Current, c.Nfiles))
bar.Start()
}
for _, file := range files {
if !c.Opts.Quiet {
bar.Increment()
}
r, err := ioutil.ReadFile(filepath.Join(c.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()
}
// Decodes image from reader
func (c *Convertor) 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
}
// Encode image to file
func (c *Convertor) encodeImage(i image.Image, filename string) (err error) {
f, err := os.Create(filename)
if err != nil {
return
}
switch filepath.Ext(filename) {
case ".png":
err = png.Encode(f, i)
case ".tif":
case ".tiff":
err = tiff.Encode(f, i, &tiff.Options{tiff.Uncompressed, false})
case ".gif":
err = gif.Encode(f, i, nil)
default:
err = jpeg.Encode(f, i, &jpeg.Options{c.Opts.Quality})
}
f.Close()
return
}
// Encode image to file (ImageMagick)
func (c *Convertor) encodeImageMagick(i image.Image, filename string) (err error) {
imagick.Initialize()
mw := imagick.NewMagickWand()
defer mw.Destroy()
b := new(bytes.Buffer)
jpeg.Encode(b, i, &jpeg.Options{c.Opts.Quality})
err = mw.ReadImageBlob(b.Bytes())
if err != nil {
fmt.Fprintf(os.Stderr, "Error ReadImageBlob: %v\n", err.Error())
return
}
if c.Opts.Grayscale {
c := mw.GetImageColors()
mw.QuantizeImage(c, imagick.COLORSPACE_GRAY, 8, true, true)
}
switch filepath.Ext(filename) {
case ".png":
mw.SetImageFormat("PNG")
mw.WriteImage(filename)
case ".gif":
mw.SetImageFormat("GIF")
mw.WriteImage(filename)
case ".bmp":
w := imagick.NewPixelWand()
w.SetColor("black")
defer w.Destroy()
cs := mw.GetImageColorspace()
if c.Opts.Grayscale {
cs = imagick.COLORSPACE_GRAY
}
mw.SetImageFormat("BMP3")
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, cs, 8, true, true)
mw.WriteImage(filename)
default:
mw.SetImageFormat("JPEG")
mw.WriteImage(filename)
}
return
}
// Lists contents of archive
func (c *Convertor) listArchive(file string) []string {
var contents []string
archive, err := unarr.NewArchive(file)
if err != nil {
fmt.Fprintf(os.Stderr, "Error NewReader: %v\n", err.Error())
}
defer archive.Close()
for {
err := archive.Entry()
if err != nil {
if err == io.EOF {
break
} else {
fmt.Fprintf(os.Stderr, "Error Entry: %v\n", err.Error())
continue
}
}
pathname := archive.Name()
contents = append(contents, pathname)
}
return contents
}
// Extracts cover from archive
func (c *Convertor) coverArchive(file string) (image.Image, error) {
var images []string
contents := c.listArchive(file)
for _, ct := range contents {
if c.isImage(ct) {
images = append(images, ct)
}
}
cover := c.getCover(images)
archive, err := unarr.NewArchive(file)
if err != nil {
return nil, err
}
defer archive.Close()
err = archive.EntryFor(cover)
if err != nil {
return nil, err
}
size := archive.Size()
buf := make([]byte, size)
for size > 0 {
n, err := archive.Read(buf)
if err != nil && err != io.EOF {
break
}
size -= n
}
if size > 0 {
return nil, errors.New("Error Read")
}
img, err := c.decodeImage(bytes.NewReader(buf), cover)
if err != nil {
return nil, err
}
return img, nil
}
// Extracts cover from document
func (c *Convertor) coverDocument(file string) (image.Image, error) {
doc, err := fitz.NewDocument(file)
if err != nil {
return nil, err
}
img, err := doc.Image(0)
if err != nil {
return nil, err
}
if img == nil {
return nil, errors.New("Image is nil")
}
return img, nil
}
// Extracts cover from directory
func (c *Convertor) coverDirectory(dir string) (image.Image, error) {
images := c.getImages(dir)
cover := c.getCover(images)
p, err := os.Open(cover)
if err != nil {
return nil, err
}
defer p.Close()
img, err := c.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 (c *Convertor) GetFiles(args []string) []string {
var files []string
walkFiles := func(fp string, f os.FileInfo, err error) error {
if !f.IsDir() {
if c.isArchive(fp) || c.isDocument(fp) {
if c.isSize(f.Size()) {
files = append(files, fp)
}
}
}
return nil
}
for _, arg := range args {
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 c.isArchive(path) || c.isDocument(path) {
if c.isSize(stat.Size()) {
files = append(files, path)
}
}
} else {
if c.Opts.Recursive {
filepath.Walk(path, walkFiles)
} else {
fs, _ := ioutil.ReadDir(path)
for _, f := range fs {
if c.isArchive(f.Name()) || c.isArchive(f.Name()) {
if c.isSize(f.Size()) {
files = append(files, filepath.Join(path, f.Name()))
}
}
}
}
if len(files) == 0 {
// append plain directory with images
files = append(files, path)
}
}
}
c.Nfiles = len(files)
return files
}
// Returns list of found image files for given directory
func (c *Convertor) 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 && c.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 c.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 (c *Convertor) getCover(images []string) string {
if len(images) == 0 {
return ""
}
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 archive
func (c *Convertor) isArchive(f string) bool {
var types = []string{".rar", ".zip", ".7z", ".gz",
".bz2", ".cbr", ".cbz", ".cb7", ".cbt"}
for _, t := range types {
if strings.ToLower(filepath.Ext(f)) == t {
return true
}
}
return false
}
// Checks if file is document
func (c *Convertor) isDocument(f string) bool {
var types = []string{".pdf", ".epub", ".xps"}
for _, t := range types {
if strings.ToLower(filepath.Ext(f)) == t {
return true
}
}
return false
}
// Checks if file is image
func (c *Convertor) isImage(f string) bool {
var types = []string{".jpg", ".jpeg", ".jpe", ".png",
".gif", ".bmp", ".tiff", ".tif", ".webp"}
for _, t := range types {
if strings.ToLower(filepath.Ext(f)) == t {
return true
}
}
return false
}
// Checks size of file
func (c *Convertor) isSize(size int64) bool {
if c.Opts.Size > 0 {
if size < c.Opts.Size*(1024*1024) {
return false
}
}
return true
}
// Checks if image is grayscale
func (c *Convertor) isGrayScale(img image.Image) bool {
model := img.ColorModel()
if model == color.GrayModel || model == color.Gray16Model {
return true
}
return false
}
// Copies reader to file
func (c *Convertor) 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 (c *Convertor) getBasename(file string) string {
basename := strings.TrimSuffix(filepath.Base(file), filepath.Ext(file))
basename = strings.TrimSuffix(basename, ".tar")
return basename
}
// Extracts cover
func (c *Convertor) ExtractCover(file string, info os.FileInfo) {
var err error
var cover image.Image
c.Current += 1
if info.IsDir() {
cover, err = c.coverDirectory(file)
} else if c.isDocument(file) {
cover, err = c.coverDocument(file)
} else {
cover, err = c.coverArchive(file)
}
if err != nil {
fmt.Fprintf(os.Stderr, "Error Cover: %v\n", err.Error())
return
}
if c.Opts.Width > 0 || c.Opts.Height > 0 {
if c.Opts.Fit {
cover = imaging.Fit(cover, c.Opts.Width, c.Opts.Height, filters[c.Opts.Filter])
} else {
cover = imaging.Resize(cover, c.Opts.Width, c.Opts.Height, filters[c.Opts.Filter])
}
}
filename := filepath.Join(c.Opts.Outdir, fmt.Sprintf("%s.jpg", c.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{c.Opts.Quality})
}
// Extracts thumbnail
func (c *Convertor) ExtractThumbnail(file string, info os.FileInfo) {
var err error
var cover image.Image
c.Current += 1
if info.IsDir() {
cover, err = c.coverDirectory(file)
} else if c.isDocument(file) {
cover, err = c.coverDocument(file)
} else {
cover, err = c.coverArchive(file)
}
if err != nil {
fmt.Fprintf(os.Stderr, "Error Thumbnail: %v\n", err.Error())
return
}
if c.Opts.Width > 0 || c.Opts.Height > 0 {
if c.Opts.Fit {
cover = imaging.Fit(cover, c.Opts.Width, c.Opts.Height, filters[c.Opts.Filter])
} else {
cover = imaging.Resize(cover, c.Opts.Width, c.Opts.Height, filters[c.Opts.Filter])
}
} else {
cover = imaging.Resize(cover, 256, 0, filters[c.Opts.Filter])
}
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(c.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 (c *Convertor) ConvertComic(file string, info os.FileInfo) {
c.Current += 1
if info.IsDir() {
c.convertDirectory(file)
c.saveArchive(file)
} else if c.isDocument(file) {
c.convertDocument(file)
c.saveArchive(file)
} else {
c.convertArchive(file)
c.saveArchive(file)
}
}