Files
cbconvert/cbconvert_test.go
T
2026-06-25 11:03:27 +02:00

815 lines
17 KiB
Go

package cbconvert
import (
"archive/zip"
"fmt"
"image"
"os"
"path/filepath"
"strings"
"testing"
)
func TestConvert(t *testing.T) {
tmpDir, err := os.MkdirTemp(os.TempDir(), "cbc")
if err != nil {
t.Error(err)
}
opts := NewOptions()
opts.OutDir = tmpDir
conv := New(opts)
files, err := conv.Files([]string{"testdata/test", "testdata"})
if err != nil {
t.Error(err)
}
for _, format := range []string{"jpeg", "png", "tiff", "bmp", "webp", "avif", "jxl"} {
conv.Opts.Format = format
for _, file := range files {
conv.Opts.Suffix = fmt.Sprintf("_%s%s", format, filepath.Ext(file.Path))
err = conv.Convert(file)
if err != nil {
t.Errorf("format %s: file %s: %v", format, file.Name, err)
}
}
}
err = os.RemoveAll(tmpDir)
if err != nil {
t.Error(err)
}
}
func TestCover(t *testing.T) {
tmpDir, err := os.MkdirTemp(os.TempDir(), "cbc")
if err != nil {
t.Error(err)
}
opts := NewOptions()
opts.OutDir = tmpDir
conv := New(opts)
files, err := conv.Files([]string{"testdata/test.cbt"})
if err != nil {
t.Error(err)
}
for _, file := range files {
err = conv.Cover(file)
if err != nil {
t.Error(err)
}
}
err = os.RemoveAll(tmpDir)
if err != nil {
t.Error(err)
}
}
func TestThumbnail(t *testing.T) {
tmpDir, err := os.MkdirTemp(os.TempDir(), "cbc")
if err != nil {
t.Error(err)
}
opts := NewOptions()
opts.OutDir = tmpDir
conv := New(opts)
files, err := conv.Files([]string{"testdata/test.pdf"})
if err != nil {
t.Error(err)
}
for _, file := range files {
err = conv.Thumbnail(file)
if err != nil {
t.Error(err)
}
}
err = os.RemoveAll(tmpDir)
if err != nil {
t.Error(err)
}
}
func TestArgs(t *testing.T) {
opts := NewOptions()
if got := opts.Args(); len(got) != 0 {
t.Errorf("defaults should emit no flags, got %v", got)
}
opts.Format = "webp"
opts.Quality = 90
opts.Effort = 4
opts.Lossless = true
opts.Width = 1200
opts.NoUpscale = true
opts.DPI = 150
opts.Grayscale = true
opts.OutDir = "/out"
got := strings.Join(opts.Args(), " ")
want := "--width 1200 --no-upscale --dpi 150 --format webp --quality 90 --effort 4 --lossless --grayscale --outdir /out"
if got != want {
t.Errorf("Args() = %q, want %q", got, want)
}
}
func TestNoUpscale(t *testing.T) {
tmpDir, err := os.MkdirTemp(os.TempDir(), "cbc")
if err != nil {
t.Fatal(err)
}
defer os.RemoveAll(tmpDir)
width := func(o Options) int {
conv := New(o)
files, err := conv.Files([]string{"testdata/test.cbz"})
if err != nil {
t.Fatal(err)
}
for _, file := range files {
if err := conv.Convert(file); err != nil {
t.Fatal(err)
}
}
return firstPage(t, conv, filepath.Join(tmpDir, "test.cbz")).Bounds().Dx()
}
base := NewOptions()
base.OutDir = tmpDir
orig := width(base)
up := NewOptions()
up.OutDir = tmpDir
up.Width = orig * 2
up.NoUpscale = true
if got := width(up); got != orig {
t.Errorf("NoUpscale should keep original width %d, got %d", orig, got)
}
no := NewOptions()
no.OutDir = tmpDir
no.Width = orig * 2
if got := width(no); got != orig*2 {
t.Errorf("without NoUpscale should upscale to %d, got %d", orig*2, got)
}
down := NewOptions()
down.OutDir = tmpDir
down.Width = orig / 2
down.NoUpscale = true
if got := width(down); got != orig/2 {
t.Errorf("NoUpscale should still downscale to %d, got %d", orig/2, got)
}
}
func TestConvertDPI(t *testing.T) {
tmpDir, err := os.MkdirTemp(os.TempDir(), "cbc")
if err != nil {
t.Fatal(err)
}
defer os.RemoveAll(tmpDir)
dims := func(dpi int) int {
opts := NewOptions()
opts.OutDir = tmpDir
opts.DPI = dpi
conv := New(opts)
files, err := conv.Files([]string{"testdata/test.pdf"})
if err != nil {
t.Fatal(err)
}
for _, file := range files {
if err := conv.Convert(file); err != nil {
t.Fatal(err)
}
}
return firstPage(t, conv, filepath.Join(tmpDir, "test.cbz")).Bounds().Dx()
}
low := dims(150)
high := dims(600)
if low >= high {
t.Errorf("higher DPI should render larger pages: 150dpi=%d, 600dpi=%d", low, high)
}
}
func TestPreviewPage(t *testing.T) {
for _, name := range []string{"testdata/test.cbz", "testdata/test.pdf"} {
conv := New(NewOptions())
files, err := conv.Files([]string{name})
if err != nil {
t.Fatal(err)
}
if len(files) != 1 {
t.Fatalf("%s: expected 1 file, got %d", name, len(files))
}
file := files[0]
n, err := conv.PageCount(file.Path, file.Stat)
if err != nil {
t.Fatal(err)
}
if n < 2 {
t.Fatalf("%s: expected >= 2 pages, got %d", name, n)
}
for _, page := range []int{0, 1, n - 1} {
img, err := conv.PreviewPage(file.Path, file.Stat, page, 0, 0)
if err != nil || img.Image == nil {
t.Fatalf("%s: page %d: img=%v err=%v", name, page, img.Image, err)
}
}
if _, err := conv.PreviewPage(file.Path, file.Stat, n, 0, 0); err == nil {
t.Errorf("%s: page %d (out of range) should error", name, n)
}
}
}
func TestConvertResize(t *testing.T) {
tmpDir, err := os.MkdirTemp(os.TempDir(), "cbc")
if err != nil {
t.Fatal(err)
}
defer os.RemoveAll(tmpDir)
opts := NewOptions()
opts.OutDir = tmpDir
opts.Width = 100
conv := New(opts)
files, err := conv.Files([]string{"testdata/test.cbz"})
if err != nil {
t.Fatal(err)
}
for _, file := range files {
if err := conv.Convert(file); err != nil {
t.Fatal(err)
}
}
img := firstPage(t, conv, filepath.Join(tmpDir, "test.cbz"))
if got := img.Bounds().Dx(); got != 100 {
t.Errorf("resized width: got %d, want 100", got)
}
}
func TestConvertFit(t *testing.T) {
tmpDir, err := os.MkdirTemp(os.TempDir(), "cbc")
if err != nil {
t.Fatal(err)
}
defer os.RemoveAll(tmpDir)
opts := NewOptions()
opts.OutDir = tmpDir
opts.Width = 120
opts.Height = 120
opts.Fit = true
conv := New(opts)
files, err := conv.Files([]string{"testdata/test.cbz"})
if err != nil {
t.Fatal(err)
}
for _, file := range files {
if err := conv.Convert(file); err != nil {
t.Fatal(err)
}
}
img := firstPage(t, conv, filepath.Join(tmpDir, "test.cbz"))
w, h := img.Bounds().Dx(), img.Bounds().Dy()
if w > 120 || h > 120 {
t.Errorf("fit exceeded bounds: got %dx%d, want <= 120x120", w, h)
}
if w != 120 && h != 120 {
t.Errorf("fit did not touch a bound: got %dx%d, want one side == 120", w, h)
}
}
func TestConvertTar(t *testing.T) {
tmpDir, err := os.MkdirTemp(os.TempDir(), "cbc")
if err != nil {
t.Fatal(err)
}
defer os.RemoveAll(tmpDir)
opts := NewOptions()
opts.OutDir = tmpDir
opts.Archive = "tar"
conv := New(opts)
files, err := conv.Files([]string{"testdata/test.cbz"})
if err != nil {
t.Fatal(err)
}
for _, file := range files {
if err := conv.Convert(file); err != nil {
t.Fatal(err)
}
}
out := filepath.Join(tmpDir, "test.cbt")
list, err := conv.archiveList(out)
if err != nil {
t.Fatalf("read tar output: %v", err)
}
if len(list) != 2 {
t.Errorf("expected 2 pages in tar output, got %d: %v", len(list), list)
}
}
func TestZipLevel(t *testing.T) {
convertWith := func(level int) *zip.ReadCloser {
tmpDir, err := os.MkdirTemp(os.TempDir(), "cbc")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { os.RemoveAll(tmpDir) })
opts := NewOptions()
opts.OutDir = tmpDir
opts.ZipLevel = level
opts.NoConvert = true
conv := New(opts)
files, err := conv.Files([]string{"testdata/test.cbz"})
if err != nil {
t.Fatal(err)
}
for _, file := range files {
if err := conv.Convert(file); err != nil {
t.Fatal(err)
}
}
zr, err := zip.OpenReader(filepath.Join(tmpDir, "test.cbz"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { zr.Close() })
return zr
}
store := convertWith(0)
for _, f := range store.File {
if f.Method != zip.Store {
t.Errorf("level 0: %s stored with method %d, want Store", f.Name, f.Method)
}
if f.CompressedSize64 != f.UncompressedSize64 {
t.Errorf("level 0: %s is compressed (%d < %d)", f.Name, f.CompressedSize64, f.UncompressedSize64)
}
}
deflate := convertWith(9)
for _, f := range deflate.File {
if f.Method != zip.Deflate {
t.Errorf("level 9: %s method %d, want Deflate", f.Name, f.Method)
}
}
}
func TestImageTransforms(t *testing.T) {
conv := New(NewOptions())
f, err := os.Open("testdata/test/00.jpg")
if err != nil {
t.Fatal(err)
}
defer f.Close()
src, err := conv.imageDecode(f)
if err != nil {
t.Fatal(err)
}
srcW, srcH := src.Bounds().Dx(), src.Bounds().Dy()
conv.Opts.Rotate = 90
rotated := conv.imageTransform(src)
if rotated.Bounds().Dx() != srcH || rotated.Bounds().Dy() != srcW {
t.Errorf("rotate 90: got %dx%d, want %dx%d", rotated.Bounds().Dx(), rotated.Bounds().Dy(), srcH, srcW)
}
conv.Opts = NewOptions()
conv.Opts.Grayscale = true
gray := conv.imageTransform(src)
if !isGrayScale(gray) {
t.Errorf("grayscale: result is not grayscale")
}
conv.Opts = NewOptions()
conv.Opts.Brightness = 20
conv.Opts.Contrast = 20
adjusted := conv.imageTransform(src)
if adjusted.Bounds().Dx() != srcW || adjusted.Bounds().Dy() != srcH {
t.Errorf("brightness/contrast changed dimensions: got %dx%d, want %dx%d",
adjusted.Bounds().Dx(), adjusted.Bounds().Dy(), srcW, srcH)
}
}
func TestCoverName(t *testing.T) {
conv := New(NewOptions())
tests := []struct {
name string
images []string
want string
}{
{"empty", nil, ""},
{"natural sort", []string{"10.jpg", "2.jpg", "1.jpg"}, "1.jpg"},
{"cover prefix wins", []string{"01.jpg", "cover.jpg", "02.jpg"}, "cover.jpg"},
{"front prefix wins", []string{"01.jpg", "front.png", "00.jpg"}, "front.png"},
{"cover suffix wins", []string{"01.jpg", "page_cover.jpg"}, "page_cover.jpg"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := conv.coverName(tt.images); got != tt.want {
t.Errorf("coverName(%v) = %q, want %q", tt.images, got, tt.want)
}
})
}
}
func TestCoverDirectory(t *testing.T) {
tmpDir, err := os.MkdirTemp(os.TempDir(), "cbc")
if err != nil {
t.Fatal(err)
}
defer os.RemoveAll(tmpDir)
opts := NewOptions()
opts.OutDir = tmpDir
conv := New(opts)
files, err := conv.Files([]string{"testdata/test"})
if err != nil {
t.Fatal(err)
}
if len(files) != 1 {
t.Fatalf("expected 1 directory file, got %d", len(files))
}
for _, file := range files {
if err := conv.Cover(file); err != nil {
t.Fatal(err)
}
}
if _, err := os.Stat(filepath.Join(tmpDir, "test.jpg")); err != nil {
t.Errorf("directory cover not written: %v", err)
}
}
func TestFileType(t *testing.T) {
tests := []struct {
path string
want string
}{
{"testdata/test.cbz", "ZIP"},
{"testdata/test.cbr", "RAR"},
{"testdata/test.cb7", "7Z"},
{"testdata/test.cbt", "TAR"},
{"testdata/test.pdf", "PDF"},
{"testdata/test", "DIR"},
}
for _, tt := range tests {
t.Run(tt.path, func(t *testing.T) {
if got := FileType(tt.path); got != tt.want {
t.Errorf("FileType(%q) = %q, want %q", tt.path, got, tt.want)
}
})
}
}
func TestCombine(t *testing.T) {
tmpDir, err := os.MkdirTemp(os.TempDir(), "cbc")
if err != nil {
t.Fatal(err)
}
defer os.RemoveAll(tmpDir)
opts := NewOptions()
opts.OutDir = tmpDir
opts.OutFile = "merged"
conv := New(opts)
files, err := conv.Files([]string{"testdata/test.cbz", "testdata/test.cbt"})
if err != nil {
t.Fatal(err)
}
if len(files) != 2 {
t.Fatalf("expected 2 input files, got %d", len(files))
}
if err := conv.Combine(files); err != nil {
t.Fatal(err)
}
zr, err := zip.OpenReader(filepath.Join(tmpDir, "merged.cbz"))
if err != nil {
t.Fatalf("open combined archive: %v", err)
}
defer zr.Close()
var names []string
for _, f := range zr.File {
names = append(names, f.Name)
}
if len(names) != 4 {
t.Fatalf("expected 4 pages in combined archive, got %d: %v", len(names), names)
}
// each input is prefixed so identically named pages do not collide
var first, second int
for _, n := range names {
switch {
case strings.HasPrefix(n, "0001_"):
first++
case strings.HasPrefix(n, "0002_"):
second++
}
}
if first != 2 || second != 2 {
t.Errorf("expected 2 pages from each input, got 0001_=%d 0002_=%d: %v", first, second, names)
}
}
func TestSubfolders(t *testing.T) {
page0, err := os.ReadFile("testdata/test/00.jpg")
if err != nil {
t.Fatal(err)
}
page1, err := os.ReadFile("testdata/test/01.jpg")
if err != nil {
t.Fatal(err)
}
inDir, err := os.MkdirTemp(os.TempDir(), "cbc-in")
if err != nil {
t.Fatal(err)
}
defer os.RemoveAll(inDir)
src := filepath.Join(inDir, "chapters.cbz")
buildZip(t, src, []zipEntry{
{"chapter1/00.jpg", page0},
{"chapter1/01.jpg", page1},
{"chapter2/00.jpg", page0},
{"chapter2/01.jpg", page1},
})
tmpDir, err := os.MkdirTemp(os.TempDir(), "cbc-out")
if err != nil {
t.Fatal(err)
}
defer os.RemoveAll(tmpDir)
opts := NewOptions()
opts.OutDir = tmpDir
conv := New(opts)
files, err := conv.Files([]string{src})
if err != nil {
t.Fatal(err)
}
for _, file := range files {
if err := conv.Convert(file); err != nil {
t.Fatal(err)
}
}
zr, err := zip.OpenReader(filepath.Join(tmpDir, "chapters.cbz"))
if err != nil {
t.Fatalf("open output archive: %v", err)
}
defer zr.Close()
// without subfolder preservation chapter2/00 overwrites chapter1/00 and only 2 pages survive
if len(zr.File) != 4 {
var names []string
for _, f := range zr.File {
names = append(names, f.Name)
}
t.Fatalf("expected 4 pages from numbered subfolders, got %d: %v", len(zr.File), names)
}
}
func TestMeta(t *testing.T) {
tmpDir, err := os.MkdirTemp(os.TempDir(), "cbc")
if err != nil {
t.Fatal(err)
}
defer os.RemoveAll(tmpDir)
// operate on a copy so the fixture stays intact
data, err := os.ReadFile("testdata/test.cbz")
if err != nil {
t.Fatal(err)
}
archive := filepath.Join(tmpDir, "meta.cbz")
if err := os.WriteFile(archive, data, 0644); err != nil {
t.Fatal(err)
}
conv := New(NewOptions())
conv.Opts = NewOptions()
conv.Opts.CommentBody = "hello world"
if _, err := conv.Meta(archive); err != nil {
t.Fatalf("set comment: %v", err)
}
conv.Opts = NewOptions()
conv.Opts.Comment = true
got, err := conv.Meta(archive)
if err != nil {
t.Fatalf("get comment: %v", err)
}
if got != "hello world" {
t.Errorf("comment roundtrip: got %q, want %q", got, "hello world")
}
extra := filepath.Join(tmpDir, "ComicInfo.xml")
if err := os.WriteFile(extra, []byte("<ComicInfo/>"), 0644); err != nil {
t.Fatal(err)
}
conv.Opts = NewOptions()
conv.Opts.FileAdd = extra
if _, err := conv.Meta(archive); err != nil {
t.Fatalf("add file: %v", err)
}
if !archiveHas(t, conv, archive, "ComicInfo.xml") {
t.Errorf("added file not found in archive")
}
conv.Opts = NewOptions()
conv.Opts.FileRemove = "ComicInfo.xml"
if _, err := conv.Meta(archive); err != nil {
t.Fatalf("remove file: %v", err)
}
if archiveHas(t, conv, archive, "ComicInfo.xml") {
t.Errorf("removed file still present in archive")
}
}
func TestRecursive(t *testing.T) {
inDir, err := os.MkdirTemp(os.TempDir(), "cbc-in")
if err != nil {
t.Fatal(err)
}
defer os.RemoveAll(inDir)
sub := filepath.Join(inDir, "chapter1")
if err := os.MkdirAll(sub, 0755); err != nil {
t.Fatal(err)
}
src, err := os.ReadFile("testdata/test.cbz")
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(sub, "test.cbz"), src, 0644); err != nil {
t.Fatal(err)
}
outDir, err := os.MkdirTemp(os.TempDir(), "cbc-out")
if err != nil {
t.Fatal(err)
}
defer os.RemoveAll(outDir)
opts := NewOptions()
opts.OutDir = outDir
opts.Recursive = true
conv := New(opts)
files, err := conv.Files([]string{inDir})
if err != nil {
t.Fatal(err)
}
if len(files) != 1 {
t.Fatalf("expected 1 file, got %d", len(files))
}
for _, file := range files {
if err := conv.Convert(file); err != nil {
t.Error(err)
}
}
// output must mirror the input subtree relative to the input root, not the absolute path
want := filepath.Join(outDir, "chapter1", "test.cbz")
if _, err := os.Stat(want); err != nil {
t.Errorf("expected output relative to input root at %s: %v", want, err)
}
}
type zipEntry struct {
name string
data []byte
}
func buildZip(t *testing.T, path string, entries []zipEntry) {
t.Helper()
f, err := os.Create(path)
if err != nil {
t.Fatal(err)
}
defer f.Close()
zw := zip.NewWriter(f)
for _, e := range entries {
w, err := zw.Create(e.name)
if err != nil {
t.Fatal(err)
}
if _, err := w.Write(e.data); err != nil {
t.Fatal(err)
}
}
if err := zw.Close(); err != nil {
t.Fatal(err)
}
}
func firstPage(t *testing.T, conv *Converter, archive string) image.Image {
t.Helper()
zr, err := zip.OpenReader(archive)
if err != nil {
t.Fatal(err)
}
defer zr.Close()
if len(zr.File) == 0 {
t.Fatalf("archive %s has no entries", archive)
}
rc, err := zr.File[0].Open()
if err != nil {
t.Fatal(err)
}
defer rc.Close()
img, err := conv.imageDecode(rc)
if err != nil {
t.Fatal(err)
}
return img
}
func archiveHas(t *testing.T, conv *Converter, archive, name string) bool {
t.Helper()
list, err := conv.archiveList(archive)
if err != nil {
t.Fatal(err)
}
for _, n := range list {
if n == name {
return true
}
}
return false
}