diff --git a/cbconvert.go b/cbconvert.go index cf59908..b3c9a53 100644 --- a/cbconvert.go +++ b/cbconvert.go @@ -4,30 +4,16 @@ import ( "bytes" "context" "crypto/md5" - "errors" "fmt" "image" _ "image/gif" "image/png" - "io" "os" "path/filepath" - "runtime" "strconv" - "sync/atomic" - - "github.com/gen2brain/avif" - "github.com/gen2brain/jpegli" - "github.com/gen2brain/jpegxl" - "github.com/gen2brain/webp" - "github.com/jsummers/gobmp" - "golang.org/x/image/tiff" pngstructure "github.com/dsoprea/go-png-image-structure" "github.com/dustin/go-humanize" - "github.com/gen2brain/go-fitz" - "github.com/gen2brain/go-unarr" - "golang.org/x/sync/errgroup" ) // Options type. @@ -151,382 +137,6 @@ func New(o Options) *Converter { return c } -// convertDocument converts PDF/EPUB document to CBZ. -func (c *Converter) convertDocument(ctx context.Context, fileName string) error { - var err error - - c.Workdir, err = os.MkdirTemp(os.TempDir(), "cbc") - if err != nil { - return fmt.Errorf("convertDocument: %w", err) - } - - doc, err := fitz.New(fileName) - if err != nil { - return fmt.Errorf("convertDocument: %w", err) - } - - defer doc.Close() - - c.Ncontents = doc.NumPage() - c.CurrContent = 0 - - if c.OnStart != nil { - c.OnStart() - } - - eg, ctx := errgroup.WithContext(ctx) - eg.SetLimit(runtime.NumCPU() + 1) - - for n := 0; n < c.Ncontents; n++ { - if ctx.Err() != nil { - return fmt.Errorf("convertDocument: %w", ctx.Err()) - } - - img, err := doc.Image(n) - if err != nil { - return fmt.Errorf("convertDocument: %w", err) - } - - if img != nil { - eg.Go(func() error { - return c.imageConvert(ctx, img, n, "") - }) - } - } - - err = eg.Wait() - if err != nil { - return fmt.Errorf("convertDocument: %w", err) - } - - return nil -} - -// convertArchive converts archive to CBZ. -func (c *Converter) convertArchive(ctx context.Context, fileName string) error { - var err error - - c.Workdir, err = os.MkdirTemp(os.TempDir(), "cbc") - if err != nil { - return fmt.Errorf("convertArchive: %w", err) - } - - contents, err := c.archiveList(fileName) - if err != nil { - return fmt.Errorf("convertArchive: %w", err) - } - - images := imagesFromSlice(contents) - - c.Ncontents = len(images) - c.CurrContent = 0 - - if c.OnStart != nil { - c.OnStart() - } - - cover := c.coverName(images) - - archive, err := unarr.NewArchive(fileName) - if err != nil { - return fmt.Errorf("convertArchive: %w", err) - } - defer archive.Close() - - eg, ctx := errgroup.WithContext(ctx) - eg.SetLimit(runtime.NumCPU() + 1) - - for { - if ctx.Err() != nil { - return fmt.Errorf("convertArchive: %w", ctx.Err()) - } - - err := archive.Entry() - if err != nil { - if errors.Is(err, io.EOF) { - break - } - - return fmt.Errorf("convertArchive: %w", err) - } - - data, err := archive.ReadAll() - if err != nil { - return fmt.Errorf("convertArchive: %w", err) - } - - pathName := archive.Name() - - if isImage(pathName) { - if c.Opts.NoConvert { - if err = copyFile(bytes.NewReader(data), filepath.Join(c.Workdir, filepath.Base(pathName))); err != nil { - return fmt.Errorf("convertArchive: %w", err) - } - - continue - } - - if cover == pathName && c.Opts.NoCover { - if err = copyFile(bytes.NewReader(data), filepath.Join(c.Workdir, filepath.Base(pathName))); err != nil { - return fmt.Errorf("convertArchive: %w", err) - } - - continue - } - - var img image.Image - img, err = c.imageDecode(bytes.NewReader(data)) - if err != nil { - return fmt.Errorf("convertArchive: %w", err) - } - - if c.Opts.NoRGB && !isGrayScale(img) { - if err = copyFile(bytes.NewReader(data), filepath.Join(c.Workdir, filepath.Base(pathName))); err != nil { - return fmt.Errorf("convertArchive: %w", err) - } - - continue - } - - if img != nil { - eg.Go(func() error { - return c.imageConvert(ctx, img, 0, pathName) - }) - } - } else if !c.Opts.NoNonImage { - if err = copyFile(bytes.NewReader(data), filepath.Join(c.Workdir, filepath.Base(pathName))); err != nil { - return fmt.Errorf("convertArchive: %w", err) - } - } - } - - err = eg.Wait() - if err != nil { - return fmt.Errorf("convertArchive: %w", err) - } - - return nil -} - -// convertDirectory converts directory to CBZ. -func (c *Converter) convertDirectory(ctx context.Context, dirPath string) error { - var err error - - c.Workdir, err = os.MkdirTemp(os.TempDir(), "cbc") - if err != nil { - return fmt.Errorf("convertDirectory: %w", err) - } - - contents, err := imagesFromPath(dirPath) - if err != nil { - return fmt.Errorf("convertDirectory: %w", err) - } - - images := imagesFromSlice(contents) - c.Ncontents = len(images) - c.CurrContent = 0 - - if c.OnStart != nil { - c.OnStart() - } - - eg, ctx := errgroup.WithContext(ctx) - eg.SetLimit(runtime.NumCPU() + 1) - - for index, img := range contents { - if ctx.Err() != nil { - return fmt.Errorf("convertDirectory: %w", ctx.Err()) - } - - file, err := os.Open(img) - if err != nil { - return fmt.Errorf("convertDirectory: %w", err) - } - - if isNonImage(img) && !c.Opts.NoNonImage { - if err = copyFile(file, filepath.Join(c.Workdir, filepath.Base(img))); err != nil { - return fmt.Errorf("convertDirectory: %w", err) - } - - if err = file.Close(); err != nil { - return fmt.Errorf("convertDirectory: %w", err) - } - - continue - } else if isImage(img) { - if c.Opts.NoConvert { - if err = copyFile(file, filepath.Join(c.Workdir, filepath.Base(img))); err != nil { - return fmt.Errorf("convertDirectory: %w", err) - } - - if err = file.Close(); err != nil { - return fmt.Errorf("convertDirectory: %w", err) - } - - continue - } - - var i image.Image - i, err = c.imageDecode(file) - if err != nil { - return fmt.Errorf("convertDirectory: %w", err) - } - - if c.Opts.NoRGB && !isGrayScale(i) { - if err = copyFile(file, filepath.Join(c.Workdir, filepath.Base(img))); err != nil { - return fmt.Errorf("convertDirectory: %w", err) - } - - if err = file.Close(); err != nil { - return fmt.Errorf("convertDirectory: %w", err) - } - - continue - } - - if err = file.Close(); err != nil { - return fmt.Errorf("convertDirectory: %w", err) - } - - if i != nil { - eg.Go(func() error { - return c.imageConvert(ctx, i, index, img) - }) - } - } - } - - err = eg.Wait() - if err != nil { - return fmt.Errorf("convertDirectory: %w", err) - } - - return nil -} - -// imageConvert converts image.Image. -func (c *Converter) imageConvert(ctx context.Context, img image.Image, index int, pathName string) error { - err := ctx.Err() - if err != nil { - return fmt.Errorf("imageConvert: %w", err) - } - - atomic.AddInt32(&c.CurrContent, 1) - if c.OnProgress != nil { - c.OnProgress() - } - - ext := c.Opts.Format - if ext == "jpeg" { - ext = "jpg" - } - - var fileName string - if pathName != "" { - fileName = filepath.Join(c.Workdir, fmt.Sprintf("%s.%s", baseNoExt(pathName), ext)) - } else { - fileName = filepath.Join(c.Workdir, fmt.Sprintf("%03d.%s", index, ext)) - } - - img = c.imageTransform(img) - - w, err := os.Create(fileName) - if err != nil { - return fmt.Errorf("imageConvert: %w", err) - } - defer w.Close() - - if err := c.imageEncode(img, w); err != nil { - return fmt.Errorf("imageConvert: %w", err) - } - - return nil -} - -// imageTransform transforms image (resize, rotate, brightness, contrast). -func (c *Converter) imageTransform(img image.Image) image.Image { - var i = img - - if c.Opts.Width > 0 || c.Opts.Height > 0 { - if c.Opts.Fit { - i = fit(i, c.Opts.Width, c.Opts.Height, filters[c.Opts.Filter]) - } else { - i = resize(i, c.Opts.Width, c.Opts.Height, filters[c.Opts.Filter]) - } - } - - if c.Opts.Rotate > 0 { - switch c.Opts.Rotate { - case 90: - i = rotate(i, 90) - case 180: - i = rotate(i, 180) - case 270: - i = rotate(i, 270) - } - } - - if c.Opts.Brightness != 0 { - i = brightness(i, float64(c.Opts.Brightness)) - } - - if c.Opts.Contrast != 0 { - i = contrast(i, float64(c.Opts.Contrast)) - } - - if c.Opts.Grayscale { - i = imageToGray(i) - } - - return i -} - -// imageDecode decodes image from reader. -func (c *Converter) imageDecode(reader io.Reader) (image.Image, error) { - img, _, err := image.Decode(reader) - if err != nil { - return img, fmt.Errorf("imageDecode: %w", err) - } - - return img, nil -} - -// imageEncode encodes image to file. -func (c *Converter) imageEncode(img image.Image, w io.Writer) error { - var err error - - switch c.Opts.Format { - case "png": - err = png.Encode(w, img) - case "tiff": - err = tiff.Encode(w, img, &tiff.Options{Compression: tiff.Uncompressed}) - case "jpeg": - opts := &jpegli.EncodingOptions{} - opts.Quality = c.Opts.Quality - opts.ChromaSubsampling = image.YCbCrSubsampleRatio420 - opts.ProgressiveLevel = 2 - opts.AdaptiveQuantization = true - opts.DCTMethod = jpegli.DefaultDCTMethod - err = jpegli.Encode(w, img, opts) - case "webp": - err = webp.Encode(w, img, webp.Options{Quality: c.Opts.Quality, Method: webp.DefaultMethod}) - case "avif": - err = avif.Encode(w, img, avif.Options{Quality: c.Opts.Quality, Speed: avif.DefaultSpeed}) - case "jxl": - err = jpegxl.Encode(w, img, jpegxl.Options{Quality: c.Opts.Quality, Effort: jpegxl.DefaultEffort}) - case "bmp": - opts := &gobmp.EncoderOptions{} - opts.SupportTransparency(false) - err = gobmp.EncodeWithOptions(w, imageToPaletted(img), opts) - } - - if err != nil { - return fmt.Errorf("imageEncode: %w", err) - } - - return nil -} - // Cancel cancels the operation. func (c *Converter) Cancel() { if c.OnCancel != nil { diff --git a/cbconvert_convert.go b/cbconvert_convert.go new file mode 100644 index 0000000..3ffa395 --- /dev/null +++ b/cbconvert_convert.go @@ -0,0 +1,401 @@ +package cbconvert + +import ( + "bytes" + "context" + "errors" + "fmt" + "image" + "image/png" + "io" + "os" + "path/filepath" + "runtime" + "sync/atomic" + + "github.com/gen2brain/avif" + "github.com/gen2brain/go-fitz" + "github.com/gen2brain/go-unarr" + "github.com/gen2brain/jpegli" + "github.com/gen2brain/jpegxl" + "github.com/gen2brain/webp" + "github.com/jsummers/gobmp" + "golang.org/x/image/tiff" + "golang.org/x/sync/errgroup" +) + +// convertDocument converts PDF/EPUB document to CBZ. +func (c *Converter) convertDocument(ctx context.Context, fileName string) error { + var err error + + c.Workdir, err = os.MkdirTemp(os.TempDir(), "cbc") + if err != nil { + return fmt.Errorf("convertDocument: %w", err) + } + + doc, err := fitz.New(fileName) + if err != nil { + return fmt.Errorf("convertDocument: %w", err) + } + + defer doc.Close() + + c.Ncontents = doc.NumPage() + c.CurrContent = 0 + + if c.OnStart != nil { + c.OnStart() + } + + eg, ctx := errgroup.WithContext(ctx) + eg.SetLimit(runtime.NumCPU() + 1) + + for n := 0; n < c.Ncontents; n++ { + if ctx.Err() != nil { + return fmt.Errorf("convertDocument: %w", ctx.Err()) + } + + img, err := doc.Image(n) + if err != nil { + return fmt.Errorf("convertDocument: %w", err) + } + + if img != nil { + eg.Go(func() error { + return c.imageConvert(ctx, img, n, "") + }) + } + } + + err = eg.Wait() + if err != nil { + return fmt.Errorf("convertDocument: %w", err) + } + + return nil +} + +// convertArchive converts archive to CBZ. +func (c *Converter) convertArchive(ctx context.Context, fileName string) error { + var err error + + c.Workdir, err = os.MkdirTemp(os.TempDir(), "cbc") + if err != nil { + return fmt.Errorf("convertArchive: %w", err) + } + + contents, err := c.archiveList(fileName) + if err != nil { + return fmt.Errorf("convertArchive: %w", err) + } + + images := imagesFromSlice(contents) + + c.Ncontents = len(images) + c.CurrContent = 0 + + if c.OnStart != nil { + c.OnStart() + } + + cover := c.coverName(images) + + archive, err := unarr.NewArchive(fileName) + if err != nil { + return fmt.Errorf("convertArchive: %w", err) + } + defer archive.Close() + + eg, ctx := errgroup.WithContext(ctx) + eg.SetLimit(runtime.NumCPU() + 1) + + for { + if ctx.Err() != nil { + return fmt.Errorf("convertArchive: %w", ctx.Err()) + } + + err := archive.Entry() + if err != nil { + if errors.Is(err, io.EOF) { + break + } + + return fmt.Errorf("convertArchive: %w", err) + } + + data, err := archive.ReadAll() + if err != nil { + return fmt.Errorf("convertArchive: %w", err) + } + + pathName := archive.Name() + + if isImage(pathName) { + if c.Opts.NoConvert { + if err = copyFile(bytes.NewReader(data), filepath.Join(c.Workdir, filepath.Base(pathName))); err != nil { + return fmt.Errorf("convertArchive: %w", err) + } + + continue + } + + if cover == pathName && c.Opts.NoCover { + if err = copyFile(bytes.NewReader(data), filepath.Join(c.Workdir, filepath.Base(pathName))); err != nil { + return fmt.Errorf("convertArchive: %w", err) + } + + continue + } + + var img image.Image + img, err = c.imageDecode(bytes.NewReader(data)) + if err != nil { + return fmt.Errorf("convertArchive: %w", err) + } + + if c.Opts.NoRGB && !isGrayScale(img) { + if err = copyFile(bytes.NewReader(data), filepath.Join(c.Workdir, filepath.Base(pathName))); err != nil { + return fmt.Errorf("convertArchive: %w", err) + } + + continue + } + + if img != nil { + eg.Go(func() error { + return c.imageConvert(ctx, img, 0, pathName) + }) + } + } else if !c.Opts.NoNonImage { + if err = copyFile(bytes.NewReader(data), filepath.Join(c.Workdir, filepath.Base(pathName))); err != nil { + return fmt.Errorf("convertArchive: %w", err) + } + } + } + + err = eg.Wait() + if err != nil { + return fmt.Errorf("convertArchive: %w", err) + } + + return nil +} + +// convertDirectory converts directory to CBZ. +func (c *Converter) convertDirectory(ctx context.Context, dirPath string) error { + var err error + + c.Workdir, err = os.MkdirTemp(os.TempDir(), "cbc") + if err != nil { + return fmt.Errorf("convertDirectory: %w", err) + } + + contents, err := imagesFromPath(dirPath) + if err != nil { + return fmt.Errorf("convertDirectory: %w", err) + } + + images := imagesFromSlice(contents) + c.Ncontents = len(images) + c.CurrContent = 0 + + if c.OnStart != nil { + c.OnStart() + } + + eg, ctx := errgroup.WithContext(ctx) + eg.SetLimit(runtime.NumCPU() + 1) + + for index, img := range contents { + if ctx.Err() != nil { + return fmt.Errorf("convertDirectory: %w", ctx.Err()) + } + + file, err := os.Open(img) + if err != nil { + return fmt.Errorf("convertDirectory: %w", err) + } + + if isNonImage(img) && !c.Opts.NoNonImage { + if err = copyFile(file, filepath.Join(c.Workdir, filepath.Base(img))); err != nil { + return fmt.Errorf("convertDirectory: %w", err) + } + + if err = file.Close(); err != nil { + return fmt.Errorf("convertDirectory: %w", err) + } + + continue + } else if isImage(img) { + if c.Opts.NoConvert { + if err = copyFile(file, filepath.Join(c.Workdir, filepath.Base(img))); err != nil { + return fmt.Errorf("convertDirectory: %w", err) + } + + if err = file.Close(); err != nil { + return fmt.Errorf("convertDirectory: %w", err) + } + + continue + } + + var i image.Image + i, err = c.imageDecode(file) + if err != nil { + return fmt.Errorf("convertDirectory: %w", err) + } + + if c.Opts.NoRGB && !isGrayScale(i) { + if err = copyFile(file, filepath.Join(c.Workdir, filepath.Base(img))); err != nil { + return fmt.Errorf("convertDirectory: %w", err) + } + + if err = file.Close(); err != nil { + return fmt.Errorf("convertDirectory: %w", err) + } + + continue + } + + if err = file.Close(); err != nil { + return fmt.Errorf("convertDirectory: %w", err) + } + + if i != nil { + eg.Go(func() error { + return c.imageConvert(ctx, i, index, img) + }) + } + } + } + + err = eg.Wait() + if err != nil { + return fmt.Errorf("convertDirectory: %w", err) + } + + return nil +} + +// imageConvert converts image.Image. +func (c *Converter) imageConvert(ctx context.Context, img image.Image, index int, pathName string) error { + err := ctx.Err() + if err != nil { + return fmt.Errorf("imageConvert: %w", err) + } + + atomic.AddInt32(&c.CurrContent, 1) + if c.OnProgress != nil { + c.OnProgress() + } + + ext := c.Opts.Format + if ext == "jpeg" { + ext = "jpg" + } + + var fileName string + if pathName != "" { + fileName = filepath.Join(c.Workdir, fmt.Sprintf("%s.%s", baseNoExt(pathName), ext)) + } else { + fileName = filepath.Join(c.Workdir, fmt.Sprintf("%03d.%s", index, ext)) + } + + img = c.imageTransform(img) + + w, err := os.Create(fileName) + if err != nil { + return fmt.Errorf("imageConvert: %w", err) + } + defer w.Close() + + if err := c.imageEncode(img, w); err != nil { + return fmt.Errorf("imageConvert: %w", err) + } + + return nil +} + +// imageTransform transforms image (resize, rotate, brightness, contrast). +func (c *Converter) imageTransform(img image.Image) image.Image { + var i = img + + if c.Opts.Width > 0 || c.Opts.Height > 0 { + if c.Opts.Fit { + i = fit(i, c.Opts.Width, c.Opts.Height, filters[c.Opts.Filter]) + } else { + i = resize(i, c.Opts.Width, c.Opts.Height, filters[c.Opts.Filter]) + } + } + + if c.Opts.Rotate > 0 { + switch c.Opts.Rotate { + case 90: + i = rotate(i, 90) + case 180: + i = rotate(i, 180) + case 270: + i = rotate(i, 270) + } + } + + if c.Opts.Brightness != 0 { + i = brightness(i, float64(c.Opts.Brightness)) + } + + if c.Opts.Contrast != 0 { + i = contrast(i, float64(c.Opts.Contrast)) + } + + if c.Opts.Grayscale { + i = imageToGray(i) + } + + return i +} + +// imageDecode decodes image from reader. +func (c *Converter) imageDecode(reader io.Reader) (image.Image, error) { + img, _, err := image.Decode(reader) + if err != nil { + return img, fmt.Errorf("imageDecode: %w", err) + } + + return img, nil +} + +// imageEncode encodes image to file. +func (c *Converter) imageEncode(img image.Image, w io.Writer) error { + var err error + + switch c.Opts.Format { + case "png": + err = png.Encode(w, img) + case "tiff": + err = tiff.Encode(w, img, &tiff.Options{Compression: tiff.Uncompressed}) + case "jpeg": + opts := &jpegli.EncodingOptions{} + opts.Quality = c.Opts.Quality + opts.ChromaSubsampling = image.YCbCrSubsampleRatio420 + opts.ProgressiveLevel = 2 + opts.AdaptiveQuantization = true + opts.DCTMethod = jpegli.DefaultDCTMethod + err = jpegli.Encode(w, img, opts) + case "webp": + err = webp.Encode(w, img, webp.Options{Quality: c.Opts.Quality, Method: webp.DefaultMethod}) + case "avif": + err = avif.Encode(w, img, avif.Options{Quality: c.Opts.Quality, Speed: avif.DefaultSpeed}) + case "jxl": + err = jpegxl.Encode(w, img, jpegxl.Options{Quality: c.Opts.Quality, Effort: jpegxl.DefaultEffort}) + case "bmp": + opts := &gobmp.EncoderOptions{} + opts.SupportTransparency(false) + err = gobmp.EncodeWithOptions(w, imageToPaletted(img), opts) + } + + if err != nil { + return fmt.Errorf("imageEncode: %w", err) + } + + return nil +}