package cbconvert import ( "bytes" "context" "crypto/md5" "fmt" "image" _ "image/gif" "image/png" "os" "path/filepath" "strconv" "strings" pngstructure "github.com/dsoprea/go-png-image-structure" "github.com/dustin/go-humanize" ) // Options type. type Options struct { // Image format, valid values are jpeg, png, tiff, bmp, webp, avif, jxl Format string // Archive format, valid values are zip, tar Archive string // ZIP compression level: -1 default, 0 store (no compression), 1-9 deflate (1 fastest, 9 smallest) ZipLevel int // JPEG image quality Quality int // Encoder speed/effort, format-specific: webp method 0-6, avif speed 0-10, jxl effort 1-10; -1 uses the format default Effort int // Combine all inputs into a single archive Combine bool // Lossless enables lossless compression (webp, avif, jxl), ignores quality Lossless bool // Image width Width int // Image height Height int // Best fit for required width and height Fit bool // Do not upscale images already smaller than the requested width/height 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 Filter int // Do not convert the cover image NoCover bool // Do not convert images that have RGB colorspace NoRGB bool // Remove non-image files from the archive NoNonImage bool // Do not transform or convert images NoConvert bool // Add suffix to file baseNoExt Suffix string // Extract cover Cover bool // Extract cover thumbnail (freedesktop spec.) Thumbnail bool // CBZ metadata Meta bool // Version Version bool // ZIP comment Comment bool // ZIP comment body CommentBody string // Add file FileAdd string // Remove file FileRemove string // Output file OutFile string // Output directory OutDir string // Convert images to grayscale (monochromatic) Grayscale bool // Rotate images, valid values are 0, 90, 180, 270 Rotate int // Adjust the brightness of the images, must be in the range (-100, 100) Brightness int // Adjust the contrast of the images, must be in the range (-100, 100) Contrast int // Process subdirectories recursively Recursive bool // Process only files larger than size (in MB) Size int // Hide console output Quiet bool } // Converter type. type Converter struct { // Options struct Opts Options // Current working directory Workdir string // Page name prefix, set per input when combining prefix string // Input root for the current file, used to build recursive output paths root string // Number of files Nfiles int // Index of the current file CurrFile int // Number of contents in archive/document Ncontents int // Index of current content CurrContent int32 // Start function OnStart func() // Progress function OnProgress func() // Compress function OnCompress func() // Cancel function OnCancel func() } // File type. type File struct { Name string Path string Root string Stat os.FileInfo SizeHuman string } // Image type. type Image struct { Image image.Image Width int Height int SizeHuman string } // NewOptions returns default options. func NewOptions() Options { o := Options{} o.Format = "jpeg" o.Archive = "zip" o.Quality = 75 o.Effort = -1 o.ZipLevel = -1 o.Filter = 2 return o } // New returns new converter. func New(o Options) *Converter { c := &Converter{} c.Opts = o return c } // Args returns the non-default options as cbconvert convert command-line flags. func (o Options) Args() []string { def := NewOptions() var args []string str := func(name, val, dflt string) { if val != dflt { args = append(args, "--"+name, val) } } num := func(name string, val, dflt int) { if val != dflt { args = append(args, "--"+name, strconv.Itoa(val)) } } flag := func(name string, val bool) { if val { args = append(args, "--"+name) } } num("width", o.Width, def.Width) num("height", o.Height, def.Height) flag("fit", o.Fit) flag("no-upscale", o.NoUpscale) num("dpi", o.DPI, def.DPI) str("format", o.Format, def.Format) str("archive", o.Archive, def.Archive) num("zip-level", o.ZipLevel, def.ZipLevel) num("quality", o.Quality, def.Quality) num("effort", o.Effort, def.Effort) flag("lossless", o.Lossless) flag("combine", o.Combine) str("outfile", o.OutFile, def.OutFile) num("filter", o.Filter, def.Filter) flag("no-cover", o.NoCover) flag("no-rgb", o.NoRGB) flag("no-nonimage", o.NoNonImage) flag("no-convert", o.NoConvert) str("suffix", o.Suffix, def.Suffix) flag("grayscale", o.Grayscale) num("rotate", o.Rotate, def.Rotate) num("brightness", o.Brightness, def.Brightness) num("contrast", o.Contrast, def.Contrast) flag("recursive", o.Recursive) str("outdir", o.OutDir, def.OutDir) num("size", o.Size, def.Size) return args } // renderDPI returns the document rendering resolution, falling back to 300 when unset. func (c *Converter) renderDPI() float64 { if c.Opts.DPI > 0 { return float64(c.Opts.DPI) } return 300 } // Cancel cancels the operation. func (c *Converter) Cancel() { if c.OnCancel != nil { c.OnCancel() } } // Files returns list of found comic files. func (c *Converter) Files(args []string) ([]File, error) { var files []File var root string toFile := func(fp string, f os.FileInfo) File { var file File file.Name = filepath.Base(fp) file.Path = fp file.Root = root file.Stat = f file.SizeHuman = humanize.IBytes(uint64(f.Size())) return file } walkFiles := func(fp string, f os.FileInfo, err error) error { if f.IsDir() { return nil } if isArchive(fp) || isDocument(fp) { if isSize(int64(c.Opts.Size), f.Size()) { files = append(files, toFile(fp, f)) } } return nil } walkDirs := func(fp string, f os.FileInfo, err error) error { if f.IsDir() { fs, err := os.ReadDir(filepath.Join(filepath.Dir(fp), f.Name())) if err != nil { return err } count := 0 for _, fn := range fs { if !fn.IsDir() && isImage(fn.Name()) { count++ } } if count > 1 { files = append(files, toFile(fp, f)) } } return nil } for _, arg := range args { path, err := filepath.Abs(arg) if err != nil { return files, fmt.Errorf("%s: %w", arg, err) } stat, err := os.Stat(path) if err != nil { return files, fmt.Errorf("%s: %w", arg, err) } if !stat.IsDir() { root = filepath.Dir(path) if isArchive(path) || isDocument(path) { if isSize(int64(c.Opts.Size), stat.Size()) { files = append(files, toFile(path, stat)) } } } else { root = path if c.Opts.Recursive { if err := filepath.Walk(path, walkFiles); err != nil { return files, fmt.Errorf("%s: %w", arg, err) } } else { fs, err := os.ReadDir(path) if err != nil { return files, fmt.Errorf("%s: %w", arg, err) } for _, f := range fs { if isArchive(f.Name()) || isDocument(f.Name()) { info, err := f.Info() if err != nil { return files, fmt.Errorf("%s: %w", arg, err) } if isSize(int64(c.Opts.Size), info.Size()) { files = append(files, toFile(filepath.Join(path, f.Name()), info)) } } } } if len(files) == 0 { // append plain directory with images if c.Opts.Recursive { if err := filepath.Walk(path, walkDirs); err != nil { return files, fmt.Errorf("%s: %w", arg, err) } } else { files = append(files, toFile(path, stat)) } } } } c.Nfiles = len(files) return files, nil } // recursiveDir mirrors the source path under OutDir, relative to the input root. func (c *Converter) recursiveDir(fileName string) string { dir := filepath.Dir(fileName) if c.root != "" { if rel, err := filepath.Rel(c.root, dir); err == nil { return filepath.Join(c.Opts.OutDir, rel) } } dir = strings.TrimPrefix(dir[len(filepath.VolumeName(dir)):], string(os.PathSeparator)) return filepath.Join(c.Opts.OutDir, dir) } // Cover extracts cover. func (c *Converter) Cover(file File) error { c.CurrFile++ c.root = file.Root fileName, fileInfo := file.Path, file.Stat cover, err := c.coverImage(fileName, fileInfo) if err != nil { return fmt.Errorf("%s: %w", fileName, err) } if c.Opts.Width > 0 || c.Opts.Height > 0 { cover = c.resizeFit(cover) } ext := c.Opts.Format if ext == "jpeg" { ext = "jpg" } var fName string if c.Opts.Recursive { outDir := c.recursiveDir(fileName) if err := os.MkdirAll(outDir, 0755); err != nil { return fmt.Errorf("%s: %w", fileName, err) } fName = filepath.Join(outDir, fmt.Sprintf("%s.%s", baseNoExt(fileName), ext)) } else { fName = filepath.Join(c.Opts.OutDir, fmt.Sprintf("%s.%s", baseNoExt(fileName), ext)) } w, err := os.Create(fName) if err != nil { return fmt.Errorf("imageConvert: %w", err) } defer w.Close() if err := c.imageEncode(cover, w); err != nil { return fmt.Errorf("%s: %w", fileName, err) } return nil } // Thumbnail extracts thumbnail. func (c *Converter) Thumbnail(file File) error { c.CurrFile++ c.root = file.Root fileName, fileInfo := file.Path, file.Stat cover, err := c.coverImage(fileName, fileInfo) if err != nil { return fmt.Errorf("%s: %w", fileName, err) } if c.Opts.Width > 0 || c.Opts.Height > 0 { cover = c.resizeFit(cover) } else { cover = resize(cover, 256, 0, filters[c.Opts.Filter]) } var buf bytes.Buffer err = png.Encode(&buf, cover) if err != nil { return fmt.Errorf("%s: %w", fileName, err) } pmp := pngstructure.NewPngMediaParser() csTmp, err := pmp.ParseBytes(buf.Bytes()) if err != nil { return fmt.Errorf("%s: %w", fileName, err) } cs, ok := csTmp.(*pngstructure.ChunkSlice) if !ok { return fmt.Errorf("%s: type is not ChunkSlice", fileName) } var fName string var fURI string if c.Opts.OutFile == "" { fURI = "file://" + fileName if c.Opts.Recursive { outDir := c.recursiveDir(fileName) if err := os.MkdirAll(outDir, 0755); err != nil { return fmt.Errorf("%s: %w", fileName, err) } fName = filepath.Join(outDir, fmt.Sprintf("%x.png", md5.Sum([]byte(fURI)))) } else { fName = filepath.Join(c.Opts.OutDir, fmt.Sprintf("%x.png", md5.Sum([]byte(fURI)))) } } else { abs, _ := filepath.Abs(c.Opts.OutFile) fURI = "file://" + abs fName = abs } chunks := cs.Chunks() textChunks := []*pngstructure.Chunk{ {Type: `tEXt`, Data: []uint8("Software\x00" + "CBconvert")}, {Type: `tEXt`, Data: []uint8("Description\x00" + "Thumbnail of " + fileName)}, {Type: `tEXt`, Data: []uint8("Thumb::URI\x00" + fURI)}, {Type: `tEXt`, Data: []uint8("Thumb::MTime\x00" + strconv.FormatInt(fileInfo.ModTime().Unix(), 10))}, {Type: `tEXt`, Data: []uint8("Thumb::Size\x00" + strconv.FormatInt(fileInfo.Size(), 10))}, } for _, textChunk := range textChunks { textChunk.Length = uint32(len(textChunk.Data)) textChunk.UpdateCrc32() } chunks = append( chunks[:1], append( textChunks, chunks[1:]..., )..., ) cs = pngstructure.NewChunkSlice(chunks) err = cs.WriteTo(&buf) if err != nil { return fmt.Errorf("%s: %w", fileName, err) } f, err := os.Create(fName) if err != nil { return fmt.Errorf("%s: %w", fileName, err) } defer f.Close() _, err = buf.WriteTo(f) if err != nil { return fmt.Errorf("%s: %w", fileName, err) } return nil } // Meta manipulates with CBZ metadata. func (c *Converter) Meta(fileName string) (any, error) { c.CurrFile++ switch { case c.Opts.Cover: var images []string contents, err := c.archiveList(fileName) if err != nil { return nil, fmt.Errorf("%s: %w", fileName, err) } for _, ct := range contents { if isImage(ct) { images = append(images, ct) } } return c.coverName(images), nil case c.Opts.Comment: comment, err := c.archiveComment(fileName) if err != nil { return nil, fmt.Errorf("%s: %w", fileName, err) } return comment, nil case c.Opts.CommentBody != "": err := c.archiveSetComment(fileName, c.Opts.CommentBody) if err != nil { return nil, fmt.Errorf("%s: %w", fileName, err) } case c.Opts.FileAdd != "": err := c.archiveFileAdd(fileName, c.Opts.FileAdd) if err != nil { return nil, fmt.Errorf("%s: %w", fileName, err) } case c.Opts.FileRemove != "": err := c.archiveFileRemove(fileName, c.Opts.FileRemove) if err != nil { return nil, fmt.Errorf("%s: %w", fileName, err) } } return "", nil } // Preview returns 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) } i = c.imageTransform(i) var w bytes.Buffer if err := c.imageEncode(i, &w); 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(len(w.Bytes()))) r := bytes.NewReader(w.Bytes()) dec, err := c.imageDecode(r) 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]) } img.Image = dec return img, nil } // Convert converts a comic book. func (c *Converter) Convert(file File) error { c.CurrFile++ c.root = file.Root fileName, fileInfo := file.Path, file.Stat ctx, cancel := context.WithCancel(context.Background()) defer cancel() c.OnCancel = cancel c.prefix = "" var err error c.Workdir, err = os.MkdirTemp(os.TempDir(), "cbc") if err != nil { return fmt.Errorf("%s: %w", fileName, err) } switch { case fileInfo.IsDir(): if err := c.convertDirectory(ctx, fileName); err != nil { return fmt.Errorf("%s: %w", fileName, err) } case isDocument(fileName): if err := c.convertDocument(ctx, fileName); err != nil { return fmt.Errorf("%s: %w", fileName, err) } case isArchive(fileName): if err := c.convertArchive(ctx, fileName); err != nil { return fmt.Errorf("%s: %w", fileName, err) } } if err := c.archiveSave(fileName); err != nil { return fmt.Errorf("%s: %w", fileName, err) } c.OnCancel = nil return nil } // Combine merges multiple comic books into a single archive. func (c *Converter) Combine(files []File) error { if len(files) == 0 { return nil } c.root = "" ctx, cancel := context.WithCancel(context.Background()) defer cancel() c.OnCancel = cancel var err error c.Workdir, err = os.MkdirTemp(os.TempDir(), "cbc") if err != nil { return fmt.Errorf("Combine: %w", err) } for i, file := range files { c.CurrFile++ c.prefix = fmt.Sprintf("%04d_", i+1) switch { case file.Stat.IsDir(): err = c.convertDirectory(ctx, file.Path) case isDocument(file.Path): err = c.convertDocument(ctx, file.Path) case isArchive(file.Path): err = c.convertArchive(ctx, file.Path) } if err != nil { return fmt.Errorf("%s: %w", file.Path, err) } } out := c.Opts.OutFile if out == "" { out = baseNoExt(files[0].Path) + "-combined" } if err := c.archiveSave(out); err != nil { return fmt.Errorf("Combine: %w", err) } c.OnCancel = nil return nil }