Files
cbconvert/cbconvert.go
T
2026-06-24 20:44:29 +02:00

679 lines
15 KiB
Go

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
// 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)
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 {
if c.Opts.Fit {
cover = fit(cover, c.Opts.Width, c.Opts.Height, filters[c.Opts.Filter])
} else {
cover = resize(cover, c.Opts.Width, c.Opts.Height, filters[c.Opts.Filter])
}
}
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 {
if c.Opts.Fit {
cover = fit(cover, c.Opts.Width, c.Opts.Height, filters[c.Opts.Filter])
} else {
cover = resize(cover, c.Opts.Width, c.Opts.Height, filters[c.Opts.Filter])
}
} 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
}